diff options
Diffstat (limited to 'kiwi/ui')
38 files changed, 9680 insertions, 0 deletions
diff --git a/kiwi/ui/__init__.py b/kiwi/ui/__init__.py new file mode 100644 index 0000000..1946454 --- /dev/null +++ b/kiwi/ui/__init__.py @@ -0,0 +1,39 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# + +"""User interface: Framework and Widget support""" + + +try: + import gtk + gtk +except ImportError: + try: + import pygtk + pygtk.require('2.0') + except: + pass + try: + import gtk + gtk + except ImportError: + raise SystemExit( + "PyGTK is required by kiwi.ui") diff --git a/kiwi/ui/comboboxentry.py b/kiwi/ui/comboboxentry.py new file mode 100644 index 0000000..9ef89b1 --- /dev/null +++ b/kiwi/ui/comboboxentry.py @@ -0,0 +1,129 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> + +"""Reimplementation of GtkComboBoxEntry in Python. + +The main difference between the L{BaseComboBoxEntry} and GtkComboBoxEntry +is that a {kiwi.ui.widgets.entry.Entry} is used instead of a GtkEntry.""" + +import gobject +import gtk + +from kiwi.python import deprecationwarn +from kiwi.ui.widgets.entry import ProxyEntry + +class BaseComboBoxEntry(gtk.ComboBox): + def __init__(self, model=None, text_column=-1): + deprecationwarn( + 'ComboBoxEntry is deprecated, use ComboEntry instead', + stacklevel=3) + + gtk.ComboBox.__init__(self) + self.entry = ProxyEntry() + # HACK: We need to set a private variable, this seems to + # be the only way of doing so + self.entry.start_editing(gtk.gdk.Event(gtk.gdk.BUTTON_PRESS)) + self.add(self.entry) + self.entry.show() + + self._text_renderer = gtk.CellRendererText() + self.pack_start(self._text_renderer, True) + self.set_active(-1) + self.entry_changed_id = self.entry.connect('changed', + self._on_entry__changed) + self._active_changed_id = self.connect("changed", + self._on_entry__active_changed) + self._has_frame_changed(None) + self.connect("notify::has-frame", self._has_frame_changed) + + if not model: + model = gtk.ListStore(str) + text_column = 0 + self.set_model(model) + self.set_text_column(text_column) + + # Virtual methods + def do_mnemnoic_activate(self, group_cycling): + self.entry.grab_focus() + return True + + def do_grab_focus(self): + self.entry.grab_focus() + + # Signal handlers + def _on_entry__active_changed(self, combobox): + iter = combobox.get_active_iter() + if not iter: + return + + self.entry.handler_block(self.entry_changed_id) + model = self.get_model() + self.entry.set_text(model[iter][self._text_column]) + self.entry.handler_unblock(self.entry_changed_id) + + def _has_frame_changed(self, pspec): + has_frame = self.get_property("has-frame") + self.entry.set_has_frame(has_frame) + + def _on_entry__changed(self, entry): + self.handler_block(self._active_changed_id) + self.set_active(-1) + self.handler_unblock(self._active_changed_id) + + # Public API + def set_text_column(self, text_column): + self._text_column = text_column + if text_column != -1: + self.set_attributes(self._text_renderer, text=text_column) + + def get_text_column(self): + return self._text_column + + # IconEntry + def set_pixbuf(self, pixbuf): + self.entry.set_pixbuf(pixbuf) + + def update_background(self, color): + self.entry.update_background(color) + + def get_icon_window(self): + return self.entry.get_icon_window() + +gobject.type_register(BaseComboBoxEntry) + +def test(): + win = gtk.Window() + win.connect('delete-event', gtk.main_quit) + + e = BaseComboBoxEntry() + win.add(e) + + m = gtk.ListStore(str) + m.append(['foo']) + m.append(['bar']) + m.append(['baz']) + e.set_model(m) + win.show_all() + gtk.main() + +if __name__ == '__main__': + test() diff --git a/kiwi/ui/comboentry.py b/kiwi/ui/comboentry.py new file mode 100644 index 0000000..11ee92f --- /dev/null +++ b/kiwi/ui/comboentry.py @@ -0,0 +1,540 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +"""Widget for displaying a list of objects""" + +import gtk +from gtk import gdk, keysyms + +from kiwi.ui.entry import KiwiEntry +from kiwi.utils import gsignal, type_register + +class _ComboEntryPopup(gtk.Window): + gsignal('text-selected', str) + def __init__(self, comboentry): + gtk.Window.__init__(self, gtk.WINDOW_POPUP) + self.add_events(gdk.BUTTON_PRESS_MASK) + self.connect('key-press-event', self._on__key_press_event) + self.connect('button-press-event', self._on__button_press_event) + self._comboentry = comboentry + + # Number of visible rows in the popup window, sensible + # default value from other toolkits + self._visible_rows = 10 + self._initial_text = None + + frame = gtk.Frame() + frame.set_shadow_type(gtk.SHADOW_ETCHED_OUT) + self.add(frame) + frame.show() + + vbox = gtk.VBox() + frame.add(vbox) + vbox.show() + + self._sw = gtk.ScrolledWindow() + self._sw.set_policy(gtk.POLICY_NEVER, gtk.POLICY_NEVER) + vbox.pack_start(self._sw) + self._sw.show() + + self._model = gtk.ListStore(str) + self._treeview = gtk.TreeView(self._model) + self._treeview.set_enable_search(False) + self._treeview.connect('motion-notify-event', + self._on_treeview__motion_notify_event) + self._treeview.connect('button-release-event', + self._on_treeview__button_release_event) + self._treeview.add_events(gdk.BUTTON_PRESS_MASK) + self._selection = self._treeview.get_selection() + self._selection.set_mode(gtk.SELECTION_BROWSE) + self._treeview.append_column( + gtk.TreeViewColumn('Foo', gtk.CellRendererText(), + text=0)) + self._treeview.set_headers_visible(False) + self._sw.add(self._treeview) + self._treeview.show() + + self._label = gtk.Label() + vbox.pack_start(self._label, False, False) + + self.set_resizable(False) + self.set_screen(comboentry.get_screen()) + + def popup(self, text=None): + """ + Shows the list of options. And optionally selects an item + @param text: text to select + """ + combo = self._comboentry + if not (combo.flags() & gtk.REALIZED): + return + + treeview = self._treeview + toplevel = combo.get_toplevel() + if isinstance(toplevel, gtk.Window) and toplevel.group: + toplevel.group.add_window(self) + + # width is meant for the popup window + # height is meant for the treeview, since it calculates using + # the height of the cells on the rows + x, y, width, height = self._get_position() + self.set_size_request(width, -1) + treeview.set_size_request(-1, height) + self.move(x, y) + self.show() + + treeview.set_hover_expand(True) + selection = treeview.get_selection() + if text: + for row in treeview.get_model(): + if text in row: + selection.select_iter(row.iter) + treeview.scroll_to_cell(row.path, use_align=True, + row_align=0.5) + treeview.set_cursor(row.path) + break + self.grab_focus() + + if not (self._treeview.flags() & gtk.HAS_FOCUS): + self._treeview.grab_focus() + + if not self._popup_grab_window(): + self.hide() + return + + self.grab_add() + + def popdown(self): + combo = self._comboentry + if not (combo.flags() & gtk.REALIZED): + return + + self.grab_remove() + self.hide() + + def set_label_text(self, text): + if text is None: + text = '' + self._label.hide() + else: + self._label.show() + self._label.set_text(text) + + def set_model(self, model): + self._treeview.set_model(model) + self._model = model + + # Callbacks + + def _on__key_press_event(self, window, event): + """ + Mimics Combobox behavior + + Escape or Alt+Up: Close + Enter, Return or Space: Select + """ + + keyval = event.keyval + state = event.state & gtk.accelerator_get_default_mod_mask() + if (keyval == keysyms.Escape or + ((keyval == keysyms.Up or keyval == keysyms.KP_Up) and + state == gdk.MOD1_MASK)): + self.popdown() + return True + elif keyval == keysyms.Tab: + self.popdown() + # XXX: private member of comboentry + self._comboentry._button.grab_focus() + return True + elif (keyval == keysyms.Return or + keyval == keysyms.space or + keyval == keysyms.KP_Enter or + keyval == keysyms.KP_Space): + model, treeiter = self._selection.get_selected() + self.emit('text-selected', model[treeiter][0]) + return True + + return False + + def _on__button_press_event(self, window, event): + # If we're clicking outside of the window + # close the popup + if (event.window != self.window or + (tuple(self.allocation.intersect( + gdk.Rectangle(x=int(event.x), y=int(event.y), + width=1, height=1)))) == (0, 0, 0, 0)): + self.popdown() + + def _on_treeview__motion_notify_event(self, treeview, event): + retval = treeview.get_path_at_pos(int(event.x), + int(event.y)) + if not retval: + return + path, column, x, y = retval + self._selection.select_path(path) + self._treeview.set_cursor(path) + + def _on_treeview__button_release_event(self, treeview, event): + retval = treeview.get_path_at_pos(int(event.x), + int(event.y)) + if not retval: + return + path, column, x, y = retval + + model = treeview.get_model() + self.emit('text-selected', model[path][0]) + + def _popup_grab_window(self): + activate_time = 0L + if gdk.pointer_grab(self.window, True, + (gdk.BUTTON_PRESS_MASK | + gdk.BUTTON_RELEASE_MASK | + gdk.POINTER_MOTION_MASK), + None, None, activate_time) == 0: + if gdk.keyboard_grab(self.window, True, activate_time) == 0: + return True + else: + self.window.get_display().pointer_ungrab(activate_time); + return False + return False + + def _get_position(self): + treeview = self._treeview + treeview.realize() + + sample = self._comboentry + + # We need to fetch the coordinates of the entry window + # since comboentry itself does not have a window + x, y = sample.entry.window.get_origin() + width = sample.allocation.width + + hpolicy = vpolicy = gtk.POLICY_NEVER + self._sw.set_policy(hpolicy, vpolicy) + + pwidth = self.size_request()[0] + if pwidth > width: + self._sw.set_policy(gtk.POLICY_ALWAYS, vpolicy) + pwidth, pheight = self.size_request() + + rows = len(self._model) + if rows > self._visible_rows: + rows = self._visible_rows + self._sw.set_policy(hpolicy, gtk.POLICY_ALWAYS) + + focus_padding = treeview.style_get_property('focus-line-width') * 2 + cell_height = treeview.get_column(0).cell_get_size()[4] + height = (cell_height + focus_padding) * rows + + screen = self._comboentry.get_screen() + monitor_num = screen.get_monitor_at_window(sample.window) + monitor = screen.get_monitor_geometry(monitor_num) + + if x < monitor.x: + x = monitor.x + elif x + width > monitor.x + monitor.width: + x = monitor.x + monitor.width - width + + if y + sample.allocation.height + height <= monitor.y + monitor.height: + y += sample.allocation.height + elif y - height >= monitor.y: + y -= height + elif (monitor.y + monitor.height - (y + sample.allocation.height) > + y - monitor.y): + y += sample.allocation.height + height = monitor.y + monitor.height - y + else : + height = y - monitor.y + y = monitor.y + + # Use half of the available screen space + max_height = monitor.height / 2 + if height > max_height: + height = int(max_height) + elif height < 0: + height = 0 + + return x, y, width, height + + def get_selected_iter(self): + return self._selection.get_selected()[1] + + def set_selected_iter(self, iter): + self._selection.select_iter(iter) +type_register(_ComboEntryPopup) + +class ComboEntry(gtk.HBox): + gsignal('changed') + gsignal('activate') + def __init__(self, entry=None): + """ + @param entry: a gtk.Entry subclass to use + """ + gtk.HBox.__init__(self) + self._popping_down = False + + if not entry: + entry = KiwiEntry() + + self.entry = entry + self.entry.connect('activate', + self._on_entry__activate) + self.entry.connect('changed', + self._on_entry__changed) + self.entry.connect('scroll-event', + self._on_entry__scroll_event) + self.entry.connect('key-press-event', + self._on_entry__key_press_event) + self.pack_start(self.entry, True, True) + self.entry.show() + + self._button = gtk.ToggleButton() + self._button.connect('scroll-event', self._on_entry__scroll_event) + self._button.connect('toggled', self._on_button__toggled) + self._button.set_focus_on_click(False) + self.pack_end(self._button, False, False) + self._button.show() + + arrow = gtk.Arrow(gtk.ARROW_DOWN, gtk.SHADOW_NONE) + self._button.add(arrow) + arrow.show() + + self._popup = _ComboEntryPopup(self) + self._popup.connect('text-selected', self._on_popup__text_selected) + self._popup.connect('hide', self._on_popup__hide) + self._popup.set_size_request(-1, 24) + + completion = gtk.EntryCompletion() + self.entry.set_completion(completion) + self.set_model(completion.get_model()) + + # Virtual methods + + def do_grab_focus(self): + self.entry.grab_focus() + + # Callbacks + + def _on_entry_completion__match_selected(self, completion, model, iter): + # the iter we receive is specific to the tree model filter used + # In the entry completion, convert it to an iter in the real model + self.set_active_iter(model.convert_iter_to_child_iter(iter)) + + def _on_entry__activate(self, entry): + self.emit('activate') + + def _on_entry__changed(self, entry): + self.emit('changed') + + def _on_entry__scroll_event(self, entry, event): + model = self.get_model() + treeiter = self._popup.get_selected_iter() + # If nothing is selected, select the first one + if not treeiter: + self.set_active_iter(model[0].iter) + return + + curr = model[treeiter].path[0] + # Scroll up, select the previous item + if event.direction == gdk.SCROLL_UP: + curr -= 1 + if curr >= 0: + self.set_active_iter(model[curr].iter) + # Scroll down, select the next item + elif event.direction == gdk.SCROLL_DOWN: + curr += 1 + if curr < len(model): + self.set_active_iter(model[curr].iter) + + def _on_entry__key_press_event(self, entry, event): + """ + Mimics Combobox behavior + + Alt+Down: Open popup + """ + keyval, state = event.keyval, event.state + state &= gtk.accelerator_get_default_mod_mask() + if ((keyval == keysyms.Down or keyval == keysyms.KP_Down) and + state == gdk.MOD1_MASK): + self.popup() + return True + + def _on_popup__hide(self, popup): + self._popping_down = True + self._button.set_active(False) + self._popping_down = False + + def _on_popup__text_selected(self, popup, text): + self.entry.set_text(text) + popup.popdown() + self.entry.grab_focus() + self.entry.set_position(len(self.entry.get_text())) + self.emit('changed') + + def _on_button__toggled(self, button): + if self._popping_down: + return + self.popup() + + # Private + + def _update(self): + model = self._model + if not len(model): + return + + iter = self._popup.get_selected_iter() + if not iter: + iter = model[0].iter + self._popup.set_selected_iter(iter) + + # Public API + + def clicked(self): + pass + + def popup(self): + """ + Hide the popup window + """ + self._popup.popup(self.entry.get_text()) + + def popdown(self): + """ + Show the popup window + """ + self._popup.popdown() + + # Entry interface + + def set_text(self, text): + """ + @param text: + """ + self.entry.set_text(text) + + def get_text(self): + """ + @returns: current text + """ + return self.entry.get_text() + + # ComboMixin interface + + def set_model(self, model): + """ + Set the tree model to model + @param model: new model + @type model: gtk.TreeModel + """ + self._model = model + self._popup.set_model(model) + completion = self.entry.get_completion() + completion.connect('match-selected', + self._on_entry_completion__match_selected) + completion.set_model(model) + + self._update() + + def get_model(self): + """ + @returns: our model + @rtype: gtk.TreeModel + """ + return self._model + + def set_active_iter(self, iter): + """ + @param iter: iter to select + @type iter: gtk.TreeIter + """ + self._popup.set_selected_iter(iter) + self.set_text(self._model[iter][0]) + + def get_active_iter(self): + """ + @returns: the selected iter + @rtype: gtk.TreeIter + """ + return self._popup.get_selected_iter() + + def prefill(self, itemdata, sort=False): + """ + See L{kiwi.ui.widgets.entry} + """ + self._model.clear() + self.entry.prefill(itemdata, sort) + + def select_item_by_data(self, data): + """ + @param data: object to select + """ + treeiter = self.entry.get_iter_by_data(data) + self.set_active_iter(treeiter) + + def select_item_by_label(self, text): + """ + @param text: text to select + """ + treeiter = self.entry.get_iter_by_label(text) + self.set_active_iter(treeiter) + + def get_selected(self): + """ + @returns: selected text or item or None if nothing + is selected + """ + treeiter = self.get_active_iter() + if treeiter: + return self.entry.get_selected_by_iter(treeiter) + + def get_selected_label(self): + """ + @returns: the label of the currently selected item + """ + treeiter = self.get_active_iter() + if treeiter: + return self.entry.get_selected_label(treeiter) + + def select(self, obj): + """ + @param obj: data or text to select + """ + treeiter = self.entry.get_iter_from_obj(obj) + self.set_active_iter(treeiter) + + def set_label_text(self, text): + self._popup.set_label_text(text) + + # IconEntry + + def set_pixbuf(self, pixbuf): + self.entry.set_pixbuf(pixbuf) + + def update_background(self, color): + self.entry.update_background(color) + + def get_icon_window(self): + return self.entry.get_icon_window() + +type_register(ComboEntry) diff --git a/kiwi/ui/combomixin.py b/kiwi/ui/combomixin.py new file mode 100644 index 0000000..d15e961 --- /dev/null +++ b/kiwi/ui/combomixin.py @@ -0,0 +1,231 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# + +import gtk + +(COL_COMBO_LABEL, + COL_COMBO_DATA) = range(2) + +(COMBO_MODE_UNKNOWN, + COMBO_MODE_STRING, + COMBO_MODE_DATA) = range(3) + +class ComboMixin(object): + """Our combos always have one model with two columns, one for the string + that is displayed and one for the object it cames from. + """ + def __init__(self): + """Call this constructor after the Combo one""" + model = gtk.ListStore(str, object) + self.set_model(model) + self.mode = COMBO_MODE_UNKNOWN + + def set_mode(self, mode): + if self.mode != COMBO_MODE_UNKNOWN: + raise AssertionError + self.mode = mode + + def __nonzero__(self): + return True + + def __len__(self): + return len(self.get_model()) + + def prefill(self, itemdata, sort=False): + """Fills the Combo with listitems corresponding to the itemdata + provided. + + Parameters: + - itemdata is a list of strings or tuples, each item corresponding + to a listitem. The simple list format is as follows:: + + >>> [ label0, label1, label2 ] + + If you require a data item to be specified for each item, use a + 2-item tuple for each element. The format is as follows:: + + >>> [ ( label0, data0 ), (label1, data1), ... ] + + - Sort is a boolean that specifies if the list is to be sorted by + label or not. By default it is not sorted + """ + if not isinstance(itemdata, (list, tuple)): + raise TypeError("'data' parameter must be a list or tuple of item " + "descriptions, found %s") % type(itemdata) + + self.clear() + if len(itemdata) == 0: + return + + if self.mode == COMBO_MODE_UNKNOWN: + first = itemdata[0] + if isinstance(first, basestring): + self.set_mode(COMBO_MODE_STRING) + elif isinstance(first, (tuple, list)): + self.set_mode(COMBO_MODE_DATA) + else: + raise TypeError("Could not determine type, items must " + "be strings or tuple/list") + + mode = self.mode + model = self.get_model() + + values = {} + if mode == COMBO_MODE_STRING: + if sort: + itemdata.sort() + + for item in itemdata: + if item in values: + raise KeyError("Tried to insert duplicate value " + "%s into Combo!" % item) + else: + values[item] = None + + model.append((item, None)) + elif mode == COMBO_MODE_DATA: + if sort: + itemdata.sort(lambda x, y: cmp(x[0], y[0])) + + for item in itemdata: + text, data = item + if text in values: + raise KeyError("Tried to insert duplicate value " + "%s into Combo!" % item) + else: + values[text] = None + model.append((text, data)) + else: + raise TypeError("Incorrect format for itemdata; see " + "docstring for more information") + + def append_item(self, label, data=None): + """ Adds a single item to the Combo. Takes: + - label: a string with the text to be added + - data: the data to be associated with that item + """ + if not isinstance(label, basestring): + raise TypeError("label must be string, found %s" % label) + + if self.mode == COMBO_MODE_UNKNOWN: + if data is not None: + self.set_mode(COMBO_MODE_DATA) + else: + self.set_mode(COMBO_MODE_STRING) + + model = self.get_model() + if self.mode == COMBO_MODE_STRING: + if data is not None: + raise TypeError("data can not be specified in string mode") + model.append((label, None)) + elif self.mode == COMBO_MODE_DATA: + if data is None: + raise TypeError("data must be specified in string mode") + model.append((label, data)) + else: + raise AssertionError + + def clear(self): + """Removes all items from list""" + model = self.get_model() + model.clear() + + def select(self, data): + mode = self.mode + if self.mode == COMBO_MODE_STRING: + self.select_item_by_label(data) + elif self.mode == COMBO_MODE_DATA: + self.select_item_by_data(data) + else: + # XXX: When setting the datatype to non string, automatically go to + # data mode + raise TypeError("unknown ComboBox mode. Did you call prefill?") + + def select_item_by_position(self, pos): + self.set_active(pos) + + def select_item_by_label(self, label): + model = self.get_model() + for row in model: + if row[COL_COMBO_LABEL] == label: + self.set_active_iter(row.iter) + break + else: + raise KeyError("No item correspond to label %r in the combo %s" + % (label, self.name)) + + def select_item_by_data(self, data): + if self.mode != COMBO_MODE_DATA: + raise TypeError("select_item_by_data can only be used in data mode") + + model = self.get_model() + for row in model: + if row[COL_COMBO_DATA] == data: + self.set_active_iter(row.iter) + break + else: + raise KeyError("No item correspond to data %r in the combo %s" + % (data, self.name)) + + def get_model_strings(self): + return [row[COL_COMBO_LABEL] for row in self.get_model()] + + def get_model_items(self): + if self.mode != COMBO_MODE_DATA: + raise TypeError("get_model_items can only be used in data mode") + + model = self.get_model() + items = {} + for row in model: + items[row[COL_COMBO_LABEL]] = row[COL_COMBO_DATA] + + return items + + def get_selected_label(self): + iter = self.get_active_iter() + if not iter: + return + + model = self.get_model() + return model[iter][COL_COMBO_LABEL] + + def get_selected_data(self): + if self.mode != COMBO_MODE_DATA: + raise TypeError("get_selected_data can only be used in data mode") + + iter = self.get_active_iter() + if not iter: + return + + model = self.get_model() + return model[iter][COL_COMBO_DATA] + + def get_selected(self): + mode = self.mode + if mode == COMBO_MODE_STRING: + return self.get_selected_label() + elif mode == COMBO_MODE_DATA: + return self.get_selected_data() + else: + raise AssertionError diff --git a/kiwi/ui/dateentry.py b/kiwi/ui/dateentry.py new file mode 100644 index 0000000..90d675e --- /dev/null +++ b/kiwi/ui/dateentry.py @@ -0,0 +1,352 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +# +# Based on date cell renderer in Planner written by Richard Hult +# and Mikael Hallendal +# + +import gettext +import datetime + +import gtk +from gtk import gdk, keysyms + +from kiwi.datatypes import converter, ValidationError +from kiwi.utils import gsignal, type_register + +_ = lambda m: gettext.dgettext('kiwi', m) + +date_converter = converter.get_converter(datetime.date) + +class _DateEntryPopup(gtk.Window): + gsignal('date-selected', object) + def __init__(self, dateentry): + gtk.Window.__init__(self, gtk.WINDOW_POPUP) + self.add_events(gdk.BUTTON_PRESS_MASK) + self.connect('key-press-event', self._on__key_press_event) + self.connect('button-press-event', self._on__button_press_event) + self._dateentry = dateentry + + frame = gtk.Frame() + frame.set_shadow_type(gtk.SHADOW_ETCHED_IN) + self.add(frame) + frame.show() + + vbox = gtk.VBox() + vbox.set_border_width(6) + frame.add(vbox) + vbox.show() + self._vbox = vbox + + self.calendar = gtk.Calendar() + self.calendar.connect('day-selected-double-click', + self._on_calendar__day_selected_double_click) + vbox.pack_start(self.calendar, False, False) + self.calendar.show() + + buttonbox = gtk.HButtonBox() + buttonbox.set_border_width(6) + buttonbox.set_layout(gtk.BUTTONBOX_SPREAD) + vbox.pack_start(buttonbox, False, False) + buttonbox.show() + + for label, callback in [(_('_Today'), self._on_today__clicked), + (_('_Cancel'), self._on_cancel__clicked), + (_('_Select'), self._on_select__clicked)]: + button = gtk.Button(label, use_underline=True) + button.connect('clicked', callback) + buttonbox.pack_start(button) + button.show() + + self.set_resizable(False) + self.set_screen(dateentry.get_screen()) + + self.realize() + self.height = self._vbox.size_request()[1] + + def _on_calendar__day_selected_double_click(self, calendar): + self.emit('date-selected', self.get_date()) + + def _on__button_press_event(self, window, event): + # If we're clicking outside of the window close the popup + if tuple(self.allocation.intersect( + gdk.Rectangle(x=int(event.x), y=int(event.y), + width=1, height=1))) == (0, 0, 0, 0): + self.popdown() + + # XXX: Clicking on button + entry + + def _on__key_press_event(self, window, event): + """ + Mimics Combobox behavior + + Escape or Alt+Up: Close + Enter, Return or Space: Select + """ + + keyval = event.keyval + state = event.state & gtk.accelerator_get_default_mod_mask() + if (keyval == keysyms.Escape or + ((keyval == keysyms.Up or keyval == keysyms.KP_Up) and + state == gdk.MOD1_MASK)): + self.popdown() + return True + elif keyval == keysyms.Tab: + self.popdown() + # XXX: private member of dateentry + self._comboentry._button.grab_focus() + return True + elif (keyval == keysyms.Return or + keyval == keysyms.space or + keyval == keysyms.KP_Enter or + keyval == keysyms.KP_Space): + self.emit('date-selected', self.get_date()) + return True + + return False + + def _on_select__clicked(self, button): + self.emit('date-selected', self.get_date()) + + def _on_cancel__clicked(self, button): + self.popdown() + + def _on_today__clicked(self, button): + self.set_date(datetime.date.today()) + + def _popup_grab_window(self): + activate_time = 0L + if gdk.pointer_grab(self.window, True, + (gdk.BUTTON_PRESS_MASK | + gdk.BUTTON_RELEASE_MASK | + gdk.POINTER_MOTION_MASK), + None, None, activate_time) == 0: + if gdk.keyboard_grab(self.window, True, activate_time) == 0: + return True + else: + self.window.get_display().pointer_ungrab(activate_time); + return False + return False + + def _get_position(self): + self.realize() + calendar = self + + sample = self._dateentry + + # We need to fetch the coordinates of the entry window + # since comboentry itself does not have a window + x, y = sample.entry.window.get_origin() + width, height = calendar.size_request() + height = self.height + + screen = sample.get_screen() + monitor_num = screen.get_monitor_at_window(sample.window) + monitor = screen.get_monitor_geometry(monitor_num) + + if x < monitor.x: + x = monitor.x + elif x + width > monitor.x + monitor.width: + x = monitor.x + monitor.width - width + + if y + sample.allocation.height + height <= monitor.y + monitor.height: + y += sample.allocation.height + elif y - height >= monitor.y: + y -= height + elif (monitor.y + monitor.height - (y + sample.allocation.height) > + y - monitor.y): + y += sample.allocation.height + height = monitor.y + monitor.height - y + else : + height = y - monitor.y + y = monitor.y + + return x, y, width, height + + def popup(self, date): + """ + Shows the list of options. And optionally selects an item + @param date: date to select + """ + combo = self._dateentry + if not (combo.flags() & gtk.REALIZED): + return + + treeview = self.calendar + if treeview.flags() & gtk.MAPPED: + return + toplevel = combo.get_toplevel() + if isinstance(toplevel, gtk.Window) and toplevel.group: + toplevel.group.add_window(self) + + x, y, width, height = self._get_position() + self.set_size_request(width, height) + self.move(x, y) + self.show_all() + + if date: + self.set_date(date) + self.grab_focus() + + if not (self.calendar.flags() & gtk.HAS_FOCUS): + self.calendar.grab_focus() + + if not self._popup_grab_window(): + self.hide() + return + + self.grab_add() + + def popdown(self): + combo = self._dateentry + if not (combo.flags() & gtk.REALIZED): + return + + self.grab_remove() + self.hide_all() + + # month in gtk.Calendar is zero-based (i.e the allowed values are 0-11) + # datetime one-based (i.e. the allowed values are 1-12) + # So convert between them + + def get_date(self): + y, m, d = self.calendar.get_date() + return datetime.date(y, m + 1, d) + + def set_date(self, date): + self.calendar.select_month(date.month - 1, date.year) + self.calendar.select_day(date.day) + # FIXME: Only mark the day in the current month? + self.calendar.clear_marks() + self.calendar.mark_day(date.day) + +class DateEntry(gtk.HBox): + gsignal('changed') + gsignal('activate') + def __init__(self): + gtk.HBox.__init__(self) + + self._popping_down = False + self._old_date = None + + # bootstrap problems, kiwi.ui.widgets.entry imports dateentry + # we need to use a proxy entry because we want the mask + from kiwi.ui.widgets.entry import ProxyEntry + self.entry = ProxyEntry() + self.entry.connect('changed', self._on_entry__changed) + self.entry.set_mask_for_data_type(datetime.date) + mask = self.entry.get_mask() + if mask: + self.entry.set_width_chars(len(mask)) + self.pack_start(self.entry, False, False) + self.entry.show() + + self._button = gtk.ToggleButton() + self._button.connect('scroll-event', self._on_entry__scroll_event) + self._button.connect('toggled', self._on_button__toggled) + self._button.set_focus_on_click(False) + self.pack_start(self._button, False, False) + self._button.show() + + arrow = gtk.Arrow(gtk.ARROW_DOWN, gtk.SHADOW_NONE) + self._button.add(arrow) + arrow.show() + + self._popup = _DateEntryPopup(self) + self._popup.connect('date-selected', self._on_popup__date_selected) + self._popup.connect('hide', self._on_popup__hide) + self._popup.set_size_request(-1, 24) + + # Virtual methods + + def do_grab_focus(self): + self.entry.grab_focus() + + # Callbacks + + def _on_entry__changed(self, entry): + self._changed(self.get_date()) + + def _on_entry__activate(self, entry): + self.emit('activate') + + def _on_entry__scroll_event(self, entry, event): + if event.direction == gdk.SCROLL_UP: + days = 1 + elif event.direction == gdk.SCROLL_DOWN: + days = -1 + else: + return + + date = self.get_date() + if not date: + newdate = datetime.date.today() + else: + newdate = date + datetime.timedelta(days=days) + self.set_date(newdate) + + def _on_button__toggled(self, button): + if self._popping_down: + return + + self._popup.popup(self.get_date()) + + def _on_popup__hide(self, popup): + self._popping_down = True + self._button.set_active(False) + self._popping_down = False + + def _on_popup__date_selected(self, popup, date): + self.set_date(date) + popup.popdown() + self.entry.grab_focus() + self.entry.set_position(len(self.entry.get_text())) + self._changed(date) + + def _changed(self, date): + if self._old_date != date: + self.emit('changed') + self._old_date = date + + # Public API + + def set_date(self, date): + """ + @param date: a datetime.date instance + """ + if not isinstance(date, datetime.date): + raise TypeError("date must be a datetime.date instance") + + self.entry.set_text(date_converter.as_string(date)) + + def get_date(self): + """ + @returns: the currently selected day + """ + try: + return date_converter.from_string(self.entry.get_text()) + except ValidationError: + pass + +type_register(DateEntry) diff --git a/kiwi/ui/delegates.py b/kiwi/ui/delegates.py new file mode 100644 index 0000000..e183229 --- /dev/null +++ b/kiwi/ui/delegates.py @@ -0,0 +1,103 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2002, 2003 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Johan Dahlin <jdahlin@async.com.br> +# + +"""Defines the Delegate classes that are included in the Kiwi Framework.""" + +from kiwi.ui.views import SlaveView, BaseView +from kiwi.controllers import BaseController + +class SlaveDelegate(SlaveView, BaseController): + """A class that combines view and controller functionality into a + single package. It does not possess a top-level window, but is instead + intended to be plugged in to a View or Delegate using attach_slave(). + """ + def __init__(self, toplevel=None, widgets=(), gladefile=None, + gladename=None, toplevel_name=None, domain=None, + keyactions=None): + """ + The keyactions parameter is sent to L{kiwi.controllers.BaseController}, + the rest are sent to L{kiwi.ui.views.SlaveView} + """ + SlaveView.__init__(self, toplevel, widgets, gladefile, gladename, + toplevel_name, domain) + BaseController.__init__(self, view=self, keyactions=keyactions) + +class Delegate(BaseView, BaseController): + """A class that combines view and controller functionality into a + single package. The Delegate class possesses a top-level window. + """ + def __init__(self, toplevel=None, widgets=(), gladefile=None, + gladename=None, toplevel_name=None, domain=None, + delete_handler=None, keyactions=None): + """Creates a new Delegate. + The keyactions parameter is sent to L{kiwi.controllers.BaseController}, + the rest are sent to L{kiwi.ui.views.BaseView} + """ + + BaseView.__init__(self, toplevel, widgets, gladefile, + gladename, toplevel_name, domain, + delete_handler) + BaseController.__init__(self, view=self, keyactions=keyactions) + +class ProxyDelegate(Delegate): + """A class that combines view, controller and proxy functionality into a + single package. The Delegate class possesses a top-level window. + + @ivar model: the model + @ivar proxy: the proxy + """ + def __init__(self, model, proxy_widgets=None, gladefile=None, + toplevel=None, widgets=(), gladename=None, + toplevel_name=None, domain=None, delete_handler=None, + keyactions=None): + """Creates a new Delegate. + @param model: instance to be attached + @param proxy_widgets: + The keyactions parameter is sent to L{kiwi.controllers.BaseController}, + the rest are sent to L{kiwi.ui.views.BaseView} + """ + + BaseView.__init__(self, toplevel, widgets, gladefile, + gladename, toplevel_name, domain, + delete_handler) + self.model = model + self.proxy = self.add_proxy(model, proxy_widgets) + self.proxy.proxy_updated = self.proxy_updated + + BaseController.__init__(self, view=self, keyactions=keyactions) + + def set_model(self, model): + """ + @param model: + """ + self.proxy.set_model(model) + self.model = model + + def proxy_updated(self, widget, attribute, value): + # Can be overriden in subclasses + pass + + def update(self, attribute): + self.proxy.update(attribute) diff --git a/kiwi/ui/dialogs.py b/kiwi/ui/dialogs.py new file mode 100644 index 0000000..6ee8f8a --- /dev/null +++ b/kiwi/ui/dialogs.py @@ -0,0 +1,326 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005,2006 Async Open Source +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +import os +import gettext + +import atk +import gtk + +__all__ = ['error', 'info', 'messagedialog', 'warning', 'yesno', 'save', + 'open', 'HIGAlertDialog', 'BaseDialog'] + +_ = gettext.gettext + +_IMAGE_TYPES = { + gtk.MESSAGE_INFO: gtk.STOCK_DIALOG_INFO, + gtk.MESSAGE_WARNING : gtk.STOCK_DIALOG_WARNING, + gtk.MESSAGE_QUESTION : gtk.STOCK_DIALOG_QUESTION, + gtk.MESSAGE_ERROR : gtk.STOCK_DIALOG_ERROR, +} + +_BUTTON_TYPES = { + gtk.BUTTONS_NONE: (), + gtk.BUTTONS_OK: (gtk.STOCK_OK, gtk.RESPONSE_OK,), + gtk.BUTTONS_CLOSE: (gtk.STOCK_CLOSE, gtk.RESPONSE_CLOSE,), + gtk.BUTTONS_CANCEL: (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,), + gtk.BUTTONS_YES_NO: (gtk.STOCK_NO, gtk.RESPONSE_NO, + gtk.STOCK_YES, gtk.RESPONSE_YES), + gtk.BUTTONS_OK_CANCEL: (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, + gtk.STOCK_OK, gtk.RESPONSE_OK) + } + +class HIGAlertDialog(gtk.Dialog): + def __init__(self, parent, flags, + type=gtk.MESSAGE_INFO, buttons=gtk.BUTTONS_NONE): + if not type in _IMAGE_TYPES: + raise TypeError( + "type must be one of: %s", ', '.join(_IMAGE_TYPES.keys())) + if not buttons in _BUTTON_TYPES: + raise TypeError( + "buttons be one of: %s", ', '.join(_BUTTON_TYPES.keys())) + + gtk.Dialog.__init__(self, '', parent, flags) + self.set_border_width(5) + self.set_resizable(False) + self.set_has_separator(False) + # Some window managers (ION) displays a default title (???) if + # the specified one is empty, workaround this by setting it + # to a single space instead + self.set_title(" ") + self.set_skip_taskbar_hint(True) + self.vbox.set_spacing(14) + self.get_accessible().set_role(atk.ROLE_ALERT) + + self._primary_label = gtk.Label() + self._secondary_label = gtk.Label() + self._details_label = gtk.Label() + self._image = gtk.image_new_from_stock(_IMAGE_TYPES[type], + gtk.ICON_SIZE_DIALOG) + self._image.set_alignment(0.5, 0.0) + + self._primary_label.set_use_markup(True) + for label in (self._primary_label, self._secondary_label, + self._details_label): + label.set_line_wrap(True) + label.set_selectable(True) + label.set_alignment(0.0, 0.5) + + hbox = gtk.HBox(False, 12) + hbox.set_border_width(5) + hbox.pack_start(self._image, False, False) + + vbox = gtk.VBox(False, 0) + hbox.pack_start(vbox, False, False) + vbox.pack_start(self._primary_label, False, False) + vbox.pack_start(self._secondary_label, False, False) + + self._expander = gtk.expander_new_with_mnemonic( + _("Show more _details")) + self._expander.set_spacing(6) + self._expander.add(self._details_label) + vbox.pack_start(self._expander, False, False) + self.vbox.pack_start(hbox, False, False) + hbox.show_all() + self._expander.hide() + self.add_buttons(*_BUTTON_TYPES[buttons]) + + def set_primary(self, text): + self._primary_label.set_markup( + "<span weight=\"bold\" size=\"larger\">%s</span>" % text) + + def set_secondary(self, text): + self._secondary_label.set_markup(text) + + def set_details(self, text): + self._details_label.set_text(text) + self._expander.show() + + def set_details_widget(self, widget): + self._expander.remove(self._details_label) + self._expander.add(widget) + widget.show() + self._expander.show() + +class BaseDialog(gtk.Dialog): + def __init__(self, parent=None, title='', flags=0, buttons=()): + if parent and not isinstance(parent, gtk.Window): + raise TypeError("parent needs to be None or a gtk.Window subclass") + + if not flags and parent: + flags &= (gtk.DIALOG_MODAL | + gtk.DIALOG_DESTROY_WITH_PARENT) + + gtk.Dialog.__init__(self, title=title, parent=parent, + flags=flags, buttons=buttons) + self.set_border_width(6) + self.set_has_separator(False) + self.vbox.set_spacing(6) + +def messagedialog(dialog_type, short, long=None, parent=None, + buttons=gtk.BUTTONS_OK, default=-1): + """Create and show a MessageDialog. + + @param dialog_type: one of constants + - gtk.MESSAGE_INFO + - gtk.MESSAGE_WARNING + - gtk.MESSAGE_QUESTION + - gtk.MESSAGE_ERROR + @param short: A header text to be inserted in the dialog. + @param long: A long description of message. + @param parent: The parent widget of this dialog + @type parent: a gtk.Window subclass + @param buttons: The button type that the dialog will be display, + one of the constants: + - gtk.BUTTONS_NONE + - gtk.BUTTONS_OK + - gtk.BUTTONS_CLOSE + - gtk.BUTTONS_CANCEL + - gtk.BUTTONS_YES_NO + - gtk.BUTTONS_OK_CANCEL + or a tuple or 2-sized tuples representing label and response. If label + is a stock-id a stock icon will be displayed. + @param default: optional default response id + """ + if buttons in (gtk.BUTTONS_NONE, gtk.BUTTONS_OK, gtk.BUTTONS_CLOSE, + gtk.BUTTONS_CANCEL, gtk.BUTTONS_YES_NO, + gtk.BUTTONS_OK_CANCEL): + dialog_buttons = buttons + buttons = [] + else: + if type(buttons) != tuple: + raise TypeError( + "buttons must be a GtkButtonsTypes constant or a tuple") + dialog_buttons = gtk.BUTTONS_NONE + + if parent and not isinstance(parent, gtk.Window): + raise TypeError("parent must be a gtk.Window subclass") + + d = HIGAlertDialog(parent=parent, flags=gtk.DIALOG_MODAL, + type=dialog_type, buttons=dialog_buttons) + for text, response in buttons: + d.add_buttons(text, response) + + d.set_primary(short) + + if long: + if isinstance(long, gtk.Widget): + d.set_details_widget(long) + elif isinstance(long, basestring): + d.set_details(long) + else: + raise TypeError("long must be a gtk.Widget or a string") + + if default != -1: + d.set_default_response(default) + + if parent: + d.set_transient_for(parent) + d.set_modal(True) + + response = d.run() + d.destroy() + return response + +def _simple(type, short, long=None, parent=None, buttons=gtk.BUTTONS_OK, + default=-1): + if buttons == gtk.BUTTONS_OK: + default = gtk.RESPONSE_OK + return messagedialog(type, short, long, + parent=parent, buttons=buttons, + default=default) + +def error(short, long=None, parent=None, buttons=gtk.BUTTONS_OK, default=-1): + return _simple(gtk.MESSAGE_ERROR, short, long, parent=parent, + buttons=buttons, default=default) + +def info(short, long=None, parent=None, buttons=gtk.BUTTONS_OK, default=-1): + return _simple(gtk.MESSAGE_INFO, short, long, parent=parent, + buttons=buttons, default=default) + +def warning(short, long=None, parent=None, buttons=gtk.BUTTONS_OK, default=-1): + return _simple(gtk.MESSAGE_WARNING, short, long, parent=parent, + buttons=buttons, default=default) + +def yesno(text, parent=None, default=gtk.RESPONSE_YES): + return messagedialog(gtk.MESSAGE_WARNING, text, None, parent, + buttons=gtk.BUTTONS_YES_NO, + default=default) + +def open(title='', parent=None, patterns=[], folder=None): + """Displays an open dialog.""" + filechooser = gtk.FileChooserDialog(title or _('Open'), + parent, + gtk.FILE_CHOOSER_ACTION_OPEN, + (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, + gtk.STOCK_OPEN, gtk.RESPONSE_OK)) + + if patterns: + file_filter = gtk.FileFilter() + for pattern in patterns: + file_filter.add_pattern(pattern) + filechooser.set_filter(file_filter) + filechooser.set_default_response(gtk.RESPONSE_OK) + + if folder: + filechooser.set_current_folder(folder) + + response = filechooser.run() + if response != gtk.RESPONSE_OK: + filechooser.destroy() + return + + path = filechooser.get_filename() + if path and os.access(path, os.R_OK): + filechooser.destroy() + return path + + abspath = os.path.abspath(path) + + error(_('Could not open file "%s"') % abspath, + _('The file "%s" could not be opened. ' + 'Permission denied.') % abspath) + + filechooser.destroy() + return path + +def ask_overwrite(filename, parent=None): + submsg1 = _('A file named "%s" already exists') % os.path.abspath(filename) + submsg2 = _('Do you wish to replace it with the current one?') + text = ('<span weight="bold" size="larger">%s</span>\n\n%s\n' + % (submsg1, submsg2)) + result = messagedialog(gtk.MESSAGE_ERROR, text, parent=parent, + buttons=((gtk.STOCK_CANCEL, + gtk.RESPONSE_CANCEL), + (_("Replace"), + gtk.RESPONSE_YES))) + return result == gtk.RESPONSE_YES + +def save(title='', parent=None, current_name='', folder=None): + """Displays a save dialog.""" + filechooser = gtk.FileChooserDialog(title or _('Save'), + parent, + gtk.FILE_CHOOSER_ACTION_SAVE, + (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, + gtk.STOCK_SAVE, gtk.RESPONSE_OK)) + if current_name: + filechooser.set_current_name(current_name) + filechooser.set_default_response(gtk.RESPONSE_OK) + + if folder: + filechooser.set_current_folder(folder) + + path = None + while True: + response = filechooser.run() + if response != gtk.RESPONSE_OK: + path = None + break + + path = filechooser.get_filename() + if not os.path.exists(path): + break + + if ask_overwrite(path, parent): + break + filechooser.destroy() + return path + +def _test(): + yesno('Kill?', default=gtk.RESPONSE_NO) + + info('Some information displayed not too long\nbut not too short', + long=('foobar ba asdjaiosjd oiadjoisjaoi aksjdasdasd kajsdhakjsdh\n' + 'askdjhaskjdha skjdhasdasdjkasldj alksdjalksjda lksdjalksdj\n' + 'asdjaslkdj alksdj lkasjdlkjasldkj alksjdlkasjd jklsdjakls\n' + 'ask;ldjaklsjdlkasjd alksdj laksjdlkasjd lkajs kjaslk jkl\n'), + default=gtk.RESPONSE_OK, + ) + + error('An error occurred', gtk.Button('Woho')) + error('Unable to mount the selected volume.', + 'mount: can\'t find /media/cdrom0 in /etc/fstab or /etc/mtab') + print open(title='Open a file', patterns=['*.py']) + print save(title='Save a file', current_name='foobar.py') + +if __name__ == '__main__': + _test() diff --git a/kiwi/ui/entry.py b/kiwi/ui/entry.py new file mode 100644 index 0000000..cd495b7 --- /dev/null +++ b/kiwi/ui/entry.py @@ -0,0 +1,603 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +""" +An enchanced version of GtkEntry that supports icons and masks +""" + +import gettext +import string + +import gobject +import pango +import gtk + +from kiwi.ui.icon import IconEntry +from kiwi.utils import PropertyObject, gsignal, gproperty, type_register + +class MaskError(Exception): + pass + +(INPUT_ASCII_LETTER, + INPUT_ALPHA, + INPUT_ALPHANUMERIC, + INPUT_DIGIT) = range(4) + +INPUT_FORMATS = { + '0': INPUT_DIGIT, + 'L': INPUT_ASCII_LETTER, + 'A': INPUT_ALPHANUMERIC, + 'a': INPUT_ALPHANUMERIC, + '&': INPUT_ALPHA, + } + +# Todo list: Other usefull Masks +# 9 - Digit, optional +# ? - Ascii letter, optional +# C - Alpha, optional + +INPUT_CHAR_MAP = { + INPUT_ASCII_LETTER: lambda text: text in string.ascii_letters, + INPUT_ALPHA: unicode.isalpha, + INPUT_ALPHANUMERIC: unicode.isalnum, + INPUT_DIGIT: unicode.isdigit, + } + + +(COL_TEXT, + COL_OBJECT) = range(2) + +(ENTRY_MODE_TEXT, + ENTRY_MODE_DATA) = range(2) + +_ = lambda msg: gettext.dgettext('kiwi', msg) + +class KiwiEntry(PropertyObject, gtk.Entry): + """ + The KiwiEntry is a Entry subclass with the following additions: + + - IconEntry, allows you to have an icon inside the entry + - Mask, force the input to meet certain requirements + - IComboMixin: Allows you work with objects instead of strings + Adds a number of convenience methods such as L{prefill}(). + """ + __gtype_name__ = 'KiwiEntry' + + gproperty("completion", bool, False) + gproperty('exact-completion', bool, default=False) + gproperty("mask", str, default='') + + def __init__(self): + gtk.Entry.__init__(self) + PropertyObject.__init__(self) + + self.connect('insert-text', self._on_insert_text) + self.connect('delete-text', self._on_delete_text) + + self._current_object = None + self._mode = ENTRY_MODE_TEXT + self._icon = IconEntry(self) + + # List of validators + # str -> static characters + # int -> dynamic, according to constants above + self._mask_validators = [] + self._mask = None + self._block_insert = False + self._block_delete = False + + # Virtual methods + + gsignal('size-allocate', 'override') + def do_size_allocate(self, allocation): + #gtk.Entry.do_size_allocate(self, allocation) + self.chain(allocation) + + if self.flags() & gtk.REALIZED: + self._icon.resize_windows() + + def do_expose_event(self, event): + gtk.Entry.do_expose_event(self, event) + + if event.window == self.window: + self._icon.draw_pixbuf() + + def do_realize(self): + gtk.Entry.do_realize(self) + self._icon.construct() + + def do_unrealize(self): + self._icon.deconstruct() + gtk.Entry.do_unrealize(self) + + # Properties + + def prop_set_exact_completion(self, value): + self.set_exact_completion(value) + return value + + def prop_set_completion(self, value): + if not self.get_completion(): + self.set_completion(gtk.EntryCompletion()) + return value + + def prop_set_mask(self, value): + try: + self.set_mask(value) + return self.get_mask() + except MaskError, e: + pass + return '' + + # Public API + def set_mask(self, mask): + """ + Sets the mask of the Entry. + Supported format characters are: + - '0' digit + - 'L' ascii letter (a-z and A-Z) + - '&' alphabet, honors the locale + - 'a' alphanumeric, honors the locale + - 'A' alphanumeric, honors the locale + + This is similar to MaskedTextBox: + U{http://msdn2.microsoft.com/en-us/library/system.windows.forms.maskedtextbox.mask(VS.80).aspx} + + Example mask for a ISO-8601 date + >>> entry.set_mask('0000-00-00') + + @param mask: the mask to set + """ + + mask = unicode(mask) + if not mask: + self.modify_font(pango.FontDescription("sans")) + self._mask = mask + return + + input_length = len(mask) + lenght = 0 + pos = 0 + while True: + if pos >= input_length: + break + if mask[pos] in INPUT_FORMATS: + self._mask_validators += [INPUT_FORMATS[mask[pos]]] + else: + self._mask_validators.append(mask[pos]) + pos += 1 + + self.modify_font(pango.FontDescription("monospace")) + + self.set_text("") + self._insert_mask(0, input_length) + self._mask = mask + + def get_mask(self): + """ + @returns: the mask + """ + return self._mask + + def get_field_text(self): + """ + Get the fields assosiated with the entry. + A field is dynamic content separated by static. + For example, the format string 000-000 has two fields + separated by a dash. + if a field is empty it'll return an empty string + otherwise it'll include the content + + @returns: fields + @rtype: list of strings + """ + if not self._mask: + raise MaskError("a mask must be set before calling get_field_text") + + def append_field(fields, field_type, s): + if s.count(' ') == len(s): + s = '' + if field_type == INPUT_DIGIT: + try: + s = int(s) + except ValueError: + s = None + fields.append(s) + + fields = [] + pos = 0 + s = '' + field_type = -1 + text = unicode(self.get_text()) + validators = self._mask_validators + while True: + if pos >= len(validators): + append_field(fields, field_type, s) + break + + validator = validators[pos] + if isinstance(validator, int): + try: + s += text[pos] + except IndexError: + s = '' + field_type = validator + else: + append_field(fields, field_type, s) + s = '' + field_type = -1 + pos += 1 + + return fields + + def get_empty_mask(self, start=None, end=None): + """ + Gets the empty mask between start and end + + @param start: + @param end: + @returns: mask + @rtype: string + """ + + if start is None: + start = 0 + if end is None: + end = len(self._mask_validators) + + s = '' + for validator in self._mask_validators[start:end]: + if isinstance(validator, int): + s += ' ' + elif isinstance(validator, unicode): + s += validator + else: + raise AssertionError + return s + + def set_exact_completion(self, value): + """ + Enable exact entry completion. + Exact means it needs to start with the value typed + and the case needs to be correct. + + @param value: enable exact completion + @type value: boolean + """ + + if value: + match_func = self._completion_exact_match_func + else: + match_func = self._completion_normal_match_func + completion = self._get_completion() + completion.set_match_func(match_func) + + # Private + + def _really_delete_text(self, start, end): + # A variant of delete_text() that never is blocked by us + self._block_delete = True + self.delete_text(start, end) + self._block_delete = False + + def _really_insert_text(self, text, position): + # A variant of insert_text() that never is blocked by us + self._block_insert = True + self.insert_text(text, position) + self._block_insert = False + + def _insert_mask(self, start, end): + text = self.get_empty_mask(start, end) + self._really_insert_text(text, position=start) + + def _confirms_to_mask(self, position, text): + validators = self._mask_validators + if position >= len(validators): + return False + + validator = validators[position] + if isinstance(validator, int): + if not INPUT_CHAR_MAP[validator](text): + return False + if isinstance(validator, unicode): + if validator == text: + return True + return False + + return True + + def _update_current_object(self, text): + if self._mode != ENTRY_MODE_DATA: + return + + for row in self.get_completion().get_model(): + if row[COL_TEXT] == text: + self._current_object = row[COL_OBJECT] + break + else: + # Customized validation + if text: + self.set_invalid(_("'%s' is not a valid object" % text)) + elif self.mandatory: + self.set_blank() + else: + self.set_valid() + self._current_object = None + + def _get_text_from_object(self, obj): + if self._mode != ENTRY_MODE_DATA: + return + + for row in self.get_completion().get_model(): + if row[COL_OBJECT] == obj: + return row[COL_TEXT] + + def _get_completion(self): + # Check so we have completion enabled, not this does not + # depend on the property, the user can manually override it, + # as long as there is a completion object set + completion = self.get_completion() + if completion: + return completion + + completion = gtk.EntryCompletion() + self.set_completion(completion) + return completion + + def set_completion(self, completion): + gtk.Entry.set_completion(self, completion) + completion.set_model(gtk.ListStore(str, object)) + completion.set_text_column(0) + self.set_exact_completion(False) + completion.connect("match-selected", + self._on_completion__match_selected) + self._current_object = None + return completion + + def _completion_exact_match_func(self, completion, _, iter): + model = completion.get_model() + if not len(model): + return + + content = model[iter][COL_TEXT] + return self.get_text().startswith(content) + + def _completion_normal_match_func(self, completion, _, iter): + model = completion.get_model() + if not len(model): + return + + content = model[iter][COL_TEXT].lower() + return self.get_text().lower() in content + + def _on_completion__match_selected(self, completion, model, iter): + if not len(model): + return + + # this updates current_object and triggers content-changed + self.set_text(model[iter][COL_TEXT]) + self.set_position(-1) + # FIXME: Enable this at some point + #self.activate() + + # Callbacks + + def _on_insert_text(self, editable, new, length, position): + if not self._mask or self._block_insert: + return + + position = self.get_position() + new = unicode(new) + for inc, c in enumerate(new): + if not self._confirms_to_mask(position + inc, c): + self.stop_emission('insert-text') + return + + self._really_delete_text(position, position+1) + + # If the next character is a static character and + # the one after the next is input, skip over + # the static character + next = position + 1 + validators = self._mask_validators + if len(validators) > next + 1: + if (isinstance(validators[next], unicode) and + isinstance(validators[next+1], int)): + # Ugly: but it must be done after the entry + # inserts the text + gobject.idle_add(self.set_position, next+1) + + def _on_delete_text(self, editable, start, end): + if not self._mask or self._block_delete: + return + + # This is tricky, quite ugly but it works. + # We want to insert the mask after the delete is done + # Instead of using idle_add we delete the text first + # insert our mask afterwards and finally blocks the call + # from happing in the entry itself + self._really_delete_text(start, end) + self._insert_mask(start, end) + + self.stop_emission('delete-text') + + # IconEntry + def set_tooltip(self, text): + self._icon.set_tooltip(text) + + def set_pixbuf(self, pixbuf): + self._icon.set_pixbuf(pixbuf) + + def update_background(self, color): + self._icon.update_background(color) + + def get_icon_window(self): + return self._icon.get_icon_window() + + # IComboMixin + + def prefill(self, itemdata, sort=False): + """Fills the Combo with listitems corresponding to the itemdata + provided. + + Parameters: + - itemdata is a list of strings or tuples, each item corresponding + to a listitem. The simple list format is as follows:: + + >>> [ label0, label1, label2 ] + + If you require a data item to be specified for each item, use a + 2-item tuple for each element. The format is as follows:: + + >>> [ ( label0, data0 ), (label1, data1), ... ] + + - Sort is a boolean that specifies if the list is to be sorted by + label or not. By default it is not sorted + """ + if not isinstance(itemdata, (list, tuple)): + raise TypeError("'data' parameter must be a list or tuple of item " + "descriptions, found %s") % type(itemdata) + + completion = self._get_completion() + model = completion.get_model() + + if len(itemdata) == 0: + model.clear() + return + + if (len(itemdata) > 0 and + type(itemdata[0]) in (tuple, list) and + len(itemdata[0]) == 2): + mode = self._mode = ENTRY_MODE_DATA + else: + mode = self._mode + + values = {} + if mode == ENTRY_MODE_TEXT: + if sort: + itemdata.sort() + + for item in itemdata: + if item in values: + raise KeyError("Tried to insert duplicate value " + "%s into Combo!" % item) + else: + values[item] = None + + model.append((item, None)) + elif mode == ENTRY_MODE_DATA: + if sort: + itemdata.sort(lambda x, y: cmp(x[0], y[0])) + + for item in itemdata: + text, data = item + if text in values: + raise KeyError("Tried to insert duplicate value " + "%s into Combo!" % item) + else: + values[text] = None + model.append((text, data)) + else: + raise TypeError("Incorrect format for itemdata; see " + "docstring for more information") + + def get_iter_by_data(self, data): + if self._mode != ENTRY_MODE_DATA: + raise TypeError( + "select_item_by_data can only be used in data mode") + + completion = self._get_completion() + model = completion.get_model() + + for row in model: + if row[COL_OBJECT] == data: + return row.iter + break + else: + raise KeyError("No item correspond to data %r in the combo %s" + % (data, self.name)) + + def get_iter_by_label(self, label): + completion = self._get_completion() + model = completion.get_model() + for row in model: + if row[COL_TEXT] == label: + return row.iter + else: + raise KeyError("No item correspond to label %r in the combo %s" + % (label, self.name)) + + def get_selected_by_iter(self, treeiter): + completion = self._get_completion() + model = completion.get_model() + mode = self._mode + text = model[treeiter][COL_TEXT] + if text != self.get_text(): + return + + if mode == ENTRY_MODE_TEXT: + return text + elif mode == ENTRY_MODE_DATA: + return model[treeiter][COL_OBJECT] + else: + raise AssertionError + + def get_selected_label(self, treeiter): + completion = self._get_completion() + model = completion.get_model() + return model[treeiter][COL_TEXT] + + def get_iter_from_obj(self, obj): + mode = self._mode + if mode == ENTRY_MODE_TEXT: + return self.get_iter_by_label(obj) + elif mode == ENTRY_MODE_DATA: + return self.get_iter_by_data(obj) + else: + # XXX: When setting the datatype to non string, automatically go to + # data mode + raise TypeError("unknown Entry mode. Did you call prefill?") + +type_register(KiwiEntry) + +def main(args): + win = gtk.Window() + win.set_title('gtk.Entry subclass') + def cb(window, event): + print 'fields', widget.get_field_text() + gtk.main_quit() + win.connect('delete-event', cb) + + widget = KiwiEntry() + widget.set_mask('000.000.000.000') + + win.add(widget) + + win.show_all() + + widget.select_region(0, 0) + gtk.main() + +if __name__ == '__main__': + import sys + sys.exit(main(sys.argv)) diff --git a/kiwi/ui/gadgets.py b/kiwi/ui/gadgets.py new file mode 100644 index 0000000..442a962 --- /dev/null +++ b/kiwi/ui/gadgets.py @@ -0,0 +1,182 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Lorenzo Gil Sanchez <lgs@sicem.biz> +# Johan Dahlin <jdahlin@async.com.br> +# + +"""Graphical utilities: color management and eyecandy""" + +import gobject +import gtk +from gtk import gdk + +from kiwi.log import Logger +from kiwi.utils import gsignal, type_register + +def gdk_color_to_string(color): + """Convert a color to a #AABBCC string""" + return "#%02X%02X%02X" % (int(color.red) >> 8, + int(color.green) >> 8, + int(color.blue) >> 8) + +def set_foreground(widget, color, state=gtk.STATE_NORMAL): + """ + Set the foreground color of a widget: + + - widget: the widget we are changing the color + - color: a hexadecimal code or a well known color name + - state: the state we are afecting, see gtk.STATE_* + """ + widget.modify_fg(state, gdk.color_parse(color)) + +def get_foreground(widget, state=gtk.STATE_NORMAL): + """Return the foreground color of the widget as a string""" + style = widget.get_style() + color = style.fg[state] + return gdk_color_to_string(color) + +def set_background(widget, color, state=gtk.STATE_NORMAL): + """ + Set the background color of a widget: + + - widget: the widget we are changing the color + - color: a hexadecimal code or a well known color name + - state: the state we are afecting, see gtk.STATE_* + """ + if isinstance(widget, gtk.Entry): + widget.modify_base(state, gdk.color_parse(color)) + else: + widget.modify_bg(state, gdk.color_parse(color)) + +def get_background(widget, state=gtk.STATE_NORMAL): + """Return the background color of the widget as a string""" + style = widget.get_style() + color = style.bg[state] + return gdk_color_to_string(color) + +def quit_if_last(*args): + windows = [toplevel + for toplevel in gtk.window_list_toplevels() + if toplevel.get_property('type') == gtk.WINDOW_TOPLEVEL] + if len(windows) == 1: + gtk.main_quit() + + +class FadeOut(gobject.GObject): + """I am a helper class to draw the fading effect of the background + Call my methods start() and stop() to control the fading. + """ + gsignal('done') + gsignal('color-changed', gdk.Color) + + # How long time it'll take before we start (in ms) + COMPLAIN_DELAY = 500 + + MERGE_COLORS_DELAY = 100 + + # XXX: Fetch the default value from the widget instead of hard coding it. + GOOD_COLOR = "white" + ERROR_COLOR = "#ffd5d5" + + def __init__(self, widget): + gobject.GObject.__init__(self) + self._widget = widget + self._background_timeout_id = -1 + self._countdown_timeout_id = -1 + self._log = Logger('fade') + self._done = False + + def _merge_colors(self, src_color, dst_color, steps=10): + """ + Change the background of widget from src_color to dst_color + in the number of steps specified + """ + self._log.debug('_merge_colors: %s -> %s' % (src_color, dst_color)) + + gdk_src = gdk.color_parse(src_color) + gdk_dst = gdk.color_parse(dst_color) + rs, gs, bs = gdk_src.red, gdk_src.green, gdk_src.blue + rd, gd, bd = gdk_dst.red, gdk_dst.green, gdk_dst.blue + rinc = (rd - rs) / float(steps) + ginc = (gd - gs) / float(steps) + binc = (bd - bs) / float(steps) + for dummy in xrange(steps): + rs += rinc + gs += ginc + bs += binc + col = gdk.color_parse("#%02X%02X%02X" % (int(rs) >> 8, + int(gs) >> 8, + int(bs) >> 8)) + self.emit('color-changed', col) + yield True + + self.emit('done') + self._background_timeout_id = -1 + self._done = True + yield False + + def _start_merging(self): + # If we changed during the delay + if self._background_timeout_id != -1: + self._log.debug('_start_merging: Already running') + return + + self._log.debug('_start_merging: Starting') + func = self._merge_colors(FadeOut.GOOD_COLOR, + FadeOut.ERROR_COLOR).next + self._background_timeout_id = ( + gobject.timeout_add(FadeOut.MERGE_COLORS_DELAY, func)) + self._countdown_timeout_id = -1 + + def start(self): + """Schedules a start of the countdown. + @returns: True if we could start, False if was already in progress + """ + if self._background_timeout_id != -1: + self._log.debug('start: Background change already running') + return False + if self._countdown_timeout_id != -1: + self._log.debug('start: Countdown already running') + return False + if self._done: + self._log.debug('start: Not running, already set') + return False + + self._log.debug('start: Scheduling') + self._countdown_timeout_id = gobject.timeout_add( + FadeOut.COMPLAIN_DELAY, self._start_merging) + + return True + + def stop(self): + """Stops the fadeout and restores the background color""" + self._log.debug('Stopping') + if self._background_timeout_id != -1: + gobject.source_remove(self._background_timeout_id) + self._background_timeout_id = -1 + if self._countdown_timeout_id != -1: + gobject.source_remove(self._countdown_timeout_id) + self._countdown_timeout_id = -1 + + self._widget.update_background(gdk.color_parse(FadeOut.GOOD_COLOR)) + self._done = False + +type_register(FadeOut) diff --git a/kiwi/ui/gazpacholoader.py b/kiwi/ui/gazpacholoader.py new file mode 100644 index 0000000..b5e4426 --- /dev/null +++ b/kiwi/ui/gazpacholoader.py @@ -0,0 +1,360 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Lorenzo Gil Sanchez <lgs@sicem.biz> +# Johan Dahlin <jdahlin@async.com.br> +# + +"""Gazpacho integration: loader and extensions""" + +import datetime +from decimal import Decimal +import gettext +import os +import warnings + +import gobject +import gtk +from gazpacho.editor import PropertyCustomEditor +from gazpacho.loader.loader import ObjectBuilder +from gazpacho.loader.custom import Adapter, ComboBoxAdapter, \ + PythonWidgetAdapter, adapter_registry +from gazpacho.properties import prop_registry, CustomProperty, StringType +from gazpacho.widgets.base.base import ContainerAdaptor +from gazpacho.widgets.base.box import BoxAdaptor + +from kiwi.datatypes import currency +from kiwi.environ import environ +from kiwi.log import Logger +from kiwi.python import disabledeprecationcall +from kiwi.ui.hyperlink import HyperLink +from kiwi.ui.objectlist import Column, ObjectList +from kiwi.ui.widgets.checkbutton import ProxyCheckButton +from kiwi.ui.widgets.combo import ProxyComboEntry, ProxyComboBox, \ + ProxyComboBoxEntry +from kiwi.ui.widgets.entry import ProxyDateEntry, ProxyEntry +from kiwi.ui.widgets.label import ProxyLabel +from kiwi.ui.widgets.radiobutton import ProxyRadioButton +from kiwi.ui.widgets.spinbutton import ProxySpinButton +from kiwi.ui.widgets.textview import ProxyTextView + +# Backwards compatibility + pyflakes +from kiwi.ui.widgets.combobox import ComboBox, ComboBoxEntry +from kiwi.ui.widgets.list import List +HyperLink +_ = gettext.gettext + +log = Logger('gazpacholoader') + +class Builder(ObjectBuilder): + def find_resource(self, filename): + return environ.find_resource("pixmaps", filename) + +class GazpachoWidgetTree: + """Example class of GladeAdaptor that uses Gazpacho loader to load the + glade files + """ + def __init__(self, view, gladefile, widgets, gladename=None, domain=None): + + if not gladefile: + raise ValueError("A gladefile wasn't provided.") + elif not isinstance(gladefile, basestring): + raise TypeError( + "gladefile should be a string, found %s" % type(gladefile)) + filename = os.path.splitext(os.path.basename(gladefile))[0] + self._filename = filename + '.glade' + self._view = view + self._gladefile = environ.find_resource("glade", self._filename) + self._widgets = (widgets or view.widgets or [])[:] + self.gladename = gladename or filename + self._showwarning = warnings.showwarning + warnings.showwarning = self._on_load_warning + self._tree = Builder(self._gladefile, domain=domain) + warnings.showwarning = self._showwarning + if not self._widgets: + self._widgets = [w.get_data("gazpacho::object-id") + for w in self._tree.get_widgets()] + self._attach_widgets() + + def _on_load_warning(self, warning, category, file, line): + self._showwarning('while loading glade file: %s' % warning, + category, self._filename, '???') + + def _attach_widgets(self): + # Attach widgets in the widgetlist to the view specified, so + # widgets = [label1, button1] -> view.label1, view.button1 + for w in self._widgets: + widget = self._tree.get_widget(w) + if widget is not None: + setattr(self._view, w, widget) + else: + log.warn("Widget %s was not found in glade widget tree." % w) + + def get_widget(self, name): + """Retrieves the named widget from the View (or glade tree)""" + name = name.replace('.', '_') + name = name.replace('-', '_') + widget = self._tree.get_widget(name) + if widget is None: + raise AttributeError( + "Widget %s not found in view %s" % (name, self._view)) + return widget + + def get_widgets(self): + return self._tree.get_widgets() + + def signal_autoconnect(self, dic): + self._tree.signal_autoconnect(dic) + + def get_sizegroups(self): + return self._tree.sizegroups + +# Normal widgets +for prop in ('normal-color', 'normal-underline', 'normal-bold', + 'hover-color', 'hover-underline', 'hover-bold', + 'active-color', 'active-underline', 'active-bold'): + prop_registry.override_simple('HyperLink::%s' % prop, editable=False) + +class HyperLinkAdaptor(ContainerAdaptor): + def fill_empty(self, context, widget): + pass + + def post_create(self, context, widget, interactive): + widget.set_text(widget.get_name()) + +class ComboEntryAdaptor(BoxAdaptor): + def get_children(self, context, comboentry): + return [] + +class DateEntryAdaptor(BoxAdaptor): + def get_children(self, context, comboentry): + return [] + +class KiwiColumnAdapter(Adapter): + object_type = Column + def construct(self, name, gtype, properties): + return Column(name) +adapter_registry.register_adapter(KiwiColumnAdapter) + +class ObjectListAdapter(PythonWidgetAdapter): + object_type = ObjectList + def construct(self, name, gtype, properties): + if gtype == List: + gtype == ObjectList + return super(ObjectListAdapter, self).construct(name, gtype, + properties) +adapter_registry.register_adapter(ObjectListAdapter) + +# Framework widgets + +class DataTypeAdaptor(PropertyCustomEditor): + def __init__(self): + super(DataTypeAdaptor, self).__init__() + self._input = self.create_editor() + + def get_editor_widget(self): + return self._input + + def get_data_types(self): + """ + Subclasses should override this. + Expected to return a list of 2 sized tuples with + name of type and type, to be used in a combo box. + """ + raise NotImplementedError + + def create_editor(self): + model = gtk.ListStore(str, object) + for datatype in self.get_data_types(): + model.append(datatype) + combo = gtk.ComboBox(model) + renderer = gtk.CellRendererText() + combo.pack_start(renderer) + combo.add_attribute(renderer, 'text', 0) + combo.set_active(0) + combo.set_data('connection-id', -1) + return combo + + def update(self, context, kiwiwidget, proxy): + combo = self._input + connection_id = combo.get_data('connection-id') + if (connection_id != -1): + combo.disconnect(connection_id) + model = combo.get_model() + connection_id = combo.connect('changed', self._editor_edit, + proxy, model) + combo.set_data('connection-id', connection_id) + value = kiwiwidget.get_property('data-type') + for row in model: + if row[1] == value: + combo.set_active_iter(row.iter) + break + + def _editor_edit(self, combo, proxy, model): + active_iter = combo.get_active_iter() + proxy.set_value(model[active_iter][1]) + +class SpinBtnDataType(DataTypeAdaptor): + def get_data_types(self): + return [ + (_('Integer'), int), + (_('Float'), float), + (_('Decimal'), Decimal), + (_('Currency'), currency) + ] + +class EntryDataType(DataTypeAdaptor): + def get_data_types(self): + return [ + (_('String'), str), + (_('Unicode'), unicode), + (_('Integer'), int), + (_('Float'), float), + (_('Decimal'), Decimal), + (_('Currency'), currency), + (_('Date'), datetime.date), + (_('Date and Time'), datetime.datetime), + (_('Time'), datetime.time), + (_('Object'), object) + ] + +class TextViewDataType(DataTypeAdaptor): + def get_data_types(self): + return [ + (_('String'), str), + (_('Unicode'), unicode), + (_('Integer'), int), + (_('Float'), float), + (_('Decimal'), Decimal), + (_('Date'), datetime.date), + ] + +class ComboBoxDataType(DataTypeAdaptor): + def get_data_types(self): + return [ + (_('String'), str), + (_('Unicode'), unicode), + (_('Boolean'), bool), + (_('Integer'), int), + (_('Float'), float), + (_('Decimal'), Decimal), + (_('Object'), object) + ] + +class LabelDataType(DataTypeAdaptor): + def get_data_types(self): + return [ + (_('String'), str), + (_('Unicode'), unicode), + (_('Boolean'), bool), + (_('Integer'), int), + (_('Float'), float), + (_('Decimal'), Decimal), + (_('Date'), datetime.date), + (_('Date and Time'), datetime.datetime), + (_('Time'), datetime.time), + (_('Currency'), currency) + ] + +class DataType(CustomProperty, StringType): + translatable = False + def save(self): + value = self.get() + if value is not None: + return value.__name__ + +class BoolOnlyDataType(CustomProperty, StringType): + translatable = False + editable = False + def save(self): + return 'bool' + +class DateOnlyDataType(CustomProperty, StringType): + translatable = False + editable = False + def save(self): + return 'date' + +class ModelProperty(CustomProperty, StringType): + translatable = False + +class DataValueProperty(CustomProperty, StringType): + translatable = False + +# Register widgets which have data-type and model-attributes +# ComboBox is a special case, it needs to inherit from another +# adapter and need to support two types. +class KiwiComboBoxAdapter(ComboBoxAdapter): + object_type = ProxyComboBox, ProxyComboBoxEntry + def construct(self, name, gtype, properties): + if gtype in (ProxyComboBox.__gtype__, + ComboBox.__gtype__): + object_type = ProxyComboBox + elif gtype in (ProxyComboBoxEntry.__gtype__, + ComboBoxEntry.__gtype__): + object_type = ProxyComboBoxEntry + else: + raise AssertionError("Unknown ComboBox GType: %r" % gtype) + + obj = disabledeprecationcall(object_type) + obj.set_name(name) + return obj +adapter_registry.register_adapter(KiwiComboBoxAdapter) + +def register_widgets(): + for gobj, editor, data_type in [ + (ProxyEntry, EntryDataType, DataType), + (ProxyDateEntry, None, DateOnlyDataType), + (ProxyCheckButton, None, BoolOnlyDataType), + (ProxyLabel, LabelDataType, DataType), + (ProxyComboBox, ComboBoxDataType, DataType), + (ProxyComboBoxEntry, ComboBoxDataType, DataType), + (ProxyComboEntry, ComboBoxDataType, DataType), + (ProxySpinButton, SpinBtnDataType, DataType), + (ProxyRadioButton, None, BoolOnlyDataType), + (ProxyTextView, TextViewDataType, DataType) + ]: + # Property overrides, used in the editor + type_name = gobject.type_name(gobj) + + data_name = type_name + '::data-type' + if editor: + prop_registry.override_simple(data_name, data_type, editor=editor) + else: + prop_registry.override_simple(data_name, data_type) + + prop_registry.override_simple(type_name + '::model-attribute', + ModelProperty) + + if issubclass(gobj, ProxyRadioButton): + prop_registry.override_simple(type_name + '::data-value', + DataValueProperty) + # Register custom adapters, since gobject.new is broken in 2.6 + # Used by loader, eg in gazpacho and in applications + # ComboBox is registered above + if gobj == ProxyComboBox: + continue + + klass = type('Kiwi%sAdapter', (PythonWidgetAdapter,), + dict(object_type=gobj)) + adapter_registry.register_adapter(klass) + +if not environ.epydoc: + register_widgets() diff --git a/kiwi/ui/hyperlink.py b/kiwi/ui/hyperlink.py new file mode 100644 index 0000000..988cef3 --- /dev/null +++ b/kiwi/ui/hyperlink.py @@ -0,0 +1,255 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): (C) Ali Afshar <aafshar@gmail.com> +# +# Contact Ali if you require release under a different license. + + +"""A hyper link widget.""" + + +from cgi import escape + +import gtk + +from kiwi.utils import gsignal, gproperty, PropertyObject, type_register + +class HyperLink(PropertyObject, gtk.EventBox): + __gtype_name__ = 'HyperLink' + """ + A hyperlink widget. + + This widget behaves much like a hyperlink from a browser. The markup that + will be displayed is contained in the properties normal-markup + hover-markup and active-markup. There is a clicked signal which is fired + when hyperlink is clicked with the left mouse button. + + Additionally, the user may set a menu that will be popped up when the user + right clicks the hyperlink. + """ + + gproperty('text', str, '') + gproperty('normal-color', str, '#0000c0') + gproperty('normal-underline', bool, False) + gproperty('normal-bold', bool, False) + gproperty('hover-color', str, '#0000c0') + gproperty('hover-underline', bool, True) + gproperty('hover-bold', bool, False) + gproperty('active-color', str, '#c00000') + gproperty('active-underline', bool, True) + gproperty('active-bold', bool, False) + + gsignal('clicked') + gsignal('right-clicked') + + def __init__(self, text=None, menu=None): + """ + Create a new hyperlink. + + @param text: The text of the hyperlink. + @type text: str + """ + gtk.EventBox.__init__(self) + PropertyObject.__init__(self) + self._gproperties = {} + if text is not None: + self.set_property('text', text) + self._is_active = False + self._is_hover = False + self._menu = menu + self._label = gtk.Label() + self.add(self._label) + self.add_events(gtk.gdk.BUTTON_PRESS_MASK | + gtk.gdk.BUTTON_RELEASE_MASK | + gtk.gdk.ENTER_NOTIFY_MASK | + gtk.gdk.LEAVE_NOTIFY_MASK) + self.connect('button-press-event', self._on_button_press_event) + self.connect('button-release-event', self._on_button_release_event) + self.connect('enter-notify-event', self._on_hover_changed, True) + self.connect('leave-notify-event', self._on_hover_changed, False) + self.connect('map-event', self._on_map_event) + self.connect('notify', self._on_notify) + self.set_text(text) + + # public API + + def get_text(self): + """ + Return the hyperlink text. + """ + return self.text + + def set_text(self, text): + """ + Set the text of the hyperlink. + + @param text: The text to set the hyperlink to. + @type text: str + """ + self.text = text + self._update_look() + + def set_menu(self, menu): + """ + Set the menu to be used for popups. + + @param menu: the gtk.Menu to be used. + @type menu: gtk.Menu + """ + self._menu = menu + + def has_menu(self): + """ + Return whether the widget has a menu set. + + @return: a boolean value indicating whether the internal menu has been + set. + """ + return self._menu is not None + + def popup(self, menu=None, button=3, etime=0L): + """ + Popup the menu and emit the popup signal. + + @param menu: The gtk.Menu to be popped up. This menu will be + used instead of the internally set menu. If this parameter is not + passed or None, the internal menu will be used. + @type menu: gtk.Menu + @param button: An integer representing the button number pressed to + cause the popup action. + @type button: int + @param etime: The time that the popup event was initiated. + @type etime: long + """ + if menu is None: + menu = self._menu + if menu is not None: + menu.popup(None, None, None, button, etime) + self.emit('right-clicked') + + def clicked(self): + """ + Fire a clicked signal. + """ + self.emit('clicked') + + def get_label(self): + """ + Get the internally stored widget. + """ + return self._label + + # private API + + def _update_look(self): + """ + Update the look of the hyperlink depending on state. + """ + if self._is_active: + state = 'active' + elif self._is_hover: + state = 'hover' + else: + state = 'normal' + color = self.get_property('%s-color' % state) + underline = self.get_property('%s-underline' % state) + bold = self.get_property('%s-bold' % state) + markup_string = self._build_markup(self.get_text() or '', + color, underline, bold) + self._label.set_markup(markup_string) + + def _build_markup(self, text, color, underline, bold): + """ + Build a marked up string depending on parameters. + """ + out = '<span color="%s">%s</span>' % (color, escape(text)) + if underline: + out = '<u>%s</u>' % out + if bold: + out = '<b>%s</b>' % out + return out + + # signal callbacks + + def _on_button_press_event(self, eventbox, event): + """ + Called on mouse down. + + Behaves in 2 ways. + 1. if left-button, register the start of a click and grab the + mouse. + 1. if right-button, emit a right-clicked signal +/- popup the + menu. + """ + if event.button == 1: + self.grab_add() + self._is_active = True + self._update_look() + elif event.button == 3: + if event.type == gtk.gdk.BUTTON_PRESS: + self.popup(button=event.button, etime=event.time) + + def _on_button_release_event(self, eventbox, event): + """ + Called on mouse up. + + If the left-button is released and the widget was earlier activated by + a mouse down event a clicked signal is fired. + """ + if event.button == 1: + self.grab_remove() + if self._is_active: + self.clicked() + self._is_active = False + self._update_look() + + def _on_hover_changed(self, eb, event, hover): + """ + Called when the mouse pinter enters or leaves the widget. + + @param hover: Whether the mouse has entered the widget. + @type hover: boolean + """ + self._is_hover = hover + self._update_look() + + def _on_notify(self, eventbox, param): + """ + Called on property notification. + + Ensure that the look is up to date with the properties + """ + if (param.name == 'text' or + param.name.endswith('-color') or + param.name.endswith('-underline') or + param.name.endswith('-bold')): + self._update_look() + + def _on_map_event(self, eventbox, event): + """ + Called on initially mapping the widget. + + Used here to set the cursor type. + """ + cursor = gtk.gdk.Cursor(gtk.gdk.HAND1) + self.window.set_cursor(cursor) + +type_register(HyperLink) diff --git a/kiwi/ui/icon.py b/kiwi/ui/icon.py new file mode 100644 index 0000000..9ff8794 --- /dev/null +++ b/kiwi/ui/icon.py @@ -0,0 +1,280 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +# +# This is tricky and contains quite a few hacks: +# An entry contains 2 GdkWindows, one for the background and one for +# the text area. The normal one, on which the (normally white) background +# is drawn can be accessed through entry.window (after realization) +# The other window is the one where the cursor and the text is drawn upon, +# it's refered to as "text area" inside the GtkEntry code and it is called +# the same here. It can only be accessed through window.get_children()[0], +# since it's considered private to the entry. +# +# +-------------------------------------+ +# | (1) | (1) parent widget (grey) +# |+----------------(2)----------------+| +# || |-- /-\ | || (2) entry.window (white) +# || |- | | |(4) (3) || +# || | \-/ | || (3) text area (transparent) +# |+-----------------------------------+| +# |-------------------------------------| (4) cursor, black +# | | +# +-------------------------------------| +# +# So, now we want to put an icon in the edge: +# An earlier approached by Lorzeno drew the icon directly on the text area, +# which is not desired since if the text is using the whole width of the +# entry the icon will be drawn on top of the text. +# Now what we want to do is to resize the text area and create a +# new window upon which we can draw the icon. +# +# +-------------------------------------+ +# | | (5) icon window +# |+----------------------------++-----+| +# || |-- /-\ | || || +# || |- | | | || (5) || +# || | \-/ | || || +# |+----------------------------++-----+| +# |-------------------------------------| +# | | +# +-------------------------------------+ +# +# When resizing the text area the cursor and text is not moved into the +# correct position, it'll still be off by the width of the icon window +# To fix this we need to call a private function, gtk_entry_recompute, +# a workaround is to call set_visiblity() which calls recompute() +# internally. +# + +"""Provides a helper classes for displaying icons in widgets. + +Currently only a L{kiwi.ui.widgets.entry.Entry} and widgets +which embed them, L{kiwi.ui.widgets.spinbutton.SpinButton} and +L{kiwi.ui.comboboxentry.BaseComboBoxEntry}. +""" + +import gtk +from gtk import gdk +from kiwi.ui.tooltip import Tooltip + +class IconEntry(object): + """ + Helper object for rendering an icon in a GtkEntry + """ + + def __init__(self, entry): + if not isinstance(entry, gtk.Entry): + raise TypeError("entry must be a gtk.Entry") + self._constructed = False + self._pixbuf = None + self._pixw = 1 + self._pixh = 1 + self._text_area = None + self._text_area_pos = (0, 0) + self._icon_win = None + self._entry = entry + self._tooltip = Tooltip(self) + entry.connect('enter-notify-event', + self._on_entry__enter_notify_event) + entry.connect('leave-notify-event', + self._on_entry__leave_notify_event) + entry.connect('notify::xalign', + self._on_entry__notify_xalign) + self._update_position() + + def _on_entry__notify_xalign(self, entry, pspec): + self._update_position() + + def _on_entry__enter_notify_event(self, entry, event): + icon_win = self.get_icon_window() + if event.window != icon_win: + return + + self._tooltip.display(entry) + + def _on_entry__leave_notify_event(self, entry, event): + if event.window != self.get_icon_window(): + return + + self._tooltip.hide() + + def set_tooltip(self, text): + self._tooltip.set_text(text) + + def get_icon_window(self): + return self._icon_win + + def set_pixbuf(self, pixbuf): + """ + @param pixbuf: a gdk.Pixbuf or None + """ + entry = self._entry + if not isinstance(entry.get_toplevel(), gtk.Window): + # For widgets in SlaveViews, wait until they're attached + # to something visible, then set the pixbuf + entry.connect_object('realize', self.set_pixbuf, pixbuf) + return + + if pixbuf: + if not isinstance(pixbuf, gdk.Pixbuf): + raise TypeError("pixbuf must be a GdkPixbuf") + else: + # Turning of the icon should also restore the background + entry.modify_base(gtk.STATE_NORMAL, None) + if not self._pixbuf: + return + self._pixbuf = pixbuf + + if pixbuf: + self._pixw = pixbuf.get_width() + self._pixh = pixbuf.get_height() + else: + self._pixw = self._pixh = 0 + + win = self._icon_win + if not win: + self.construct() + win = self._icon_win + + self.resize_windows() + + # XXX: Why? + if win: + if not pixbuf: + win.hide() + else: + win.show() + + # Hack: This triggers a .recompute() which is private + entry.set_visibility(entry.get_visibility()) + entry.queue_draw() + + def construct(self): + if self._constructed: + return + + entry = self._entry + if not entry.flags() & gtk.REALIZED: + entry.realize() + + # Hack: Save a reference to the text area, now when its created + self._text_area = entry.window.get_children()[0] + self._text_area_pos = self._text_area.get_position() + + # PyGTK should allow default values for most of the values here. + win = gtk.gdk.Window(entry.window, + self._pixw, self._pixh, + gtk.gdk.WINDOW_CHILD, + (gtk.gdk.ENTER_NOTIFY_MASK | + gtk.gdk.LEAVE_NOTIFY_MASK), + gtk.gdk.INPUT_OUTPUT, + 'icon window', + 0, 0, + entry.get_visual(), + entry.get_colormap(), + gtk.gdk.Cursor(entry.get_display(), gdk.LEFT_PTR), + '', '', True) + self._icon_win = win + win.set_user_data(entry) + win.set_background(entry.style.base[entry.state]) + self._constructed = True + + def deconstruct(self): + if self._icon_win: + # This is broken on PyGTK 2.6.x + try: + self._icon_win.set_user_data(None) + except: + pass + # Destroy not needed, called by the GC. + self._icon_win = None + + def update_background(self, color): + if not self._icon_win: + return + + self._entry.modify_base(gtk.STATE_NORMAL, color) + + self.draw_pixbuf() + + def resize_windows(self): + if not self._pixbuf: + return + + icony = iconx = 4 + + # Make space for the icon, both windows + winw = self._entry.window.get_size()[0] + textw, texth = self._text_area.get_size() + textw = winw - self._pixw - (iconx + icony) + + if self._pos == gtk.POS_LEFT: + textx, texty = self._text_area_pos + textx += iconx + self._pixw + + # FIXME: Why is this needed. Focus padding? + # The text jumps without this + textw -= 2 + self._text_area.move_resize(textx, texty, textw, texth) + elif self._pos == gtk.POS_RIGHT: + self._text_area.resize(textw, texth) + iconx += textw + + icon_win = self._icon_win + # XXX: Why? + if not icon_win: + return + + # If the size of the window is large enough, resize and move it + # Otherwise just move it to the right side of the entry + if icon_win.get_size() != (self._pixw, self._pixh): + icon_win.move_resize(iconx, icony, self._pixw, self._pixh) + else: + icon_win.move(iconx, icony) + + def draw_pixbuf(self): + if not self._pixbuf: + return + + win = self._icon_win + # XXX: Why? + if not win: + return + + # Draw background first + color = self._entry.style.base_gc[self._entry.state] + win.draw_rectangle(color, True, + 0, 0, self._pixw, self._pixh) + + # If sensitive draw the icon, regardless of the window emitting the + # event since makes it a bit smoother on resize + if self._entry.flags() & gtk.SENSITIVE: + win.draw_pixbuf(None, self._pixbuf, 0, 0, 0, 0, + self._pixw, self._pixh) + + def _update_position(self): + if self._entry.get_property('xalign') > 0.5: + self._pos = gtk.POS_LEFT + else: + self._pos = gtk.POS_RIGHT diff --git a/kiwi/ui/libgladeloader.py b/kiwi/ui/libgladeloader.py new file mode 100644 index 0000000..d3c2872 --- /dev/null +++ b/kiwi/ui/libgladeloader.py @@ -0,0 +1,76 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +import os + +from gtk.glade import XML + +from kiwi.environ import environ +from kiwi.log import Logger + +log = Logger('libgladeloader') + +class LibgladeWidgetTree(XML): + def __init__(self, view, gladefile, widgets, gladename=None, domain=None): + + if not gladefile: + raise ValueError("A gladefile wasn't provided.") + elif not isinstance(gladefile, basestring): + raise TypeError( + "gladefile should be a string, found %s" % type(gladefile)) + filename = os.path.splitext(os.path.basename(gladefile))[0] + + self._view = view + self._gladefile = environ.find_resource("glade", filename + ".glade") + self._widgets = (widgets or view.widgets or [])[:] + self.gladename = gladename or filename + XML.__init__(self, self._gladefile, domain) + if not self._widgets: + self._widgets = [w.get_name() for w in self.get_widget_prefix('')] + self._attach_widgets() + + def _attach_widgets(self): + # Attach widgets in the widgetlist to the view specified, so + # widgets = [label1, button1] -> view.label1, view.button1 + for w in self._widgets: + widget = XML.get_widget(self, w) + if widget is not None: + setattr(self._view, w, widget) + else: + log.warn("Widget %s was not found in glade widget tree." % w) + + def get_widget(self, name): + """Retrieves the named widget from the View (or glade tree)""" + name = name.replace('.', '_') + widget = XML.get_widget(self, name) + + if widget is None: + raise AttributeError( + "Widget %s not found in view %s" % (name, self._view)) + return widget + + def get_widgets(self): + return self.get_widget_prefix('') + + def get_sizegroups(self): + return [] diff --git a/kiwi/ui/objectlist.py b/kiwi/ui/objectlist.py new file mode 100644 index 0000000..d6b1720 --- /dev/null +++ b/kiwi/ui/objectlist.py @@ -0,0 +1,1705 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2001-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> +# + +"""High level wrapper for GtkTreeView""" + +import datetime +import gettext + +import gobject +import gtk +from gtk import gdk + +from kiwi.accessor import kgetattr +from kiwi.datatypes import converter, currency, number +from kiwi.log import Logger +from kiwi.python import slicerange +from kiwi.utils import PropertyObject, gsignal, gproperty, type_register + +_ = gettext.gettext + +log = Logger('objectlist') + +str2type = converter.str_to_type + +def str2enum(value_name, enum_class): + "converts a string to a enum" + for _, enum in enum_class.__enum_values__.items(): + if value_name in (enum.value_name, enum.value_nick): + return enum + +def str2bool(value, from_string=converter.from_string): + "converts a boolean to a enum" + return from_string(bool, value) + +class Column(PropertyObject, gobject.GObject): + """Specifies a column for an L{ObjectList}""" + gproperty('title', str) + gproperty('data-type', object) + gproperty('visible', bool, default=True) + gproperty('justify', gtk.Justification, default=gtk.JUSTIFY_LEFT) + gproperty('format', str) + gproperty('width', int, maximum=2**16) + gproperty('sorted', bool, default=False) + gproperty('order', gtk.SortType, default=gtk.SORT_ASCENDING) + gproperty('expand', bool, default=False) + gproperty('tooltip', str) + gproperty('format_func', object) + gproperty('editable', bool, default=False) + gproperty('searchable', bool, default=False) + gproperty('radio', bool, default=False) + gproperty('cache', bool, default=False) + gproperty('use-stock', bool, default=False) + gproperty('icon-size', gtk.IconSize, default=gtk.ICON_SIZE_MENU) + gproperty('editable_attribute', str) + #gproperty('title_pixmap', str) + + # This can be set in subclasses, to be able to allow custom + # cell_data_functions, used by SequentialColumn + cell_data_func = None + + # This is called after the renderer property is set, to allow + # us to set custom rendering properties + renderer_func = None + + # This is called when the renderer is created, so we can set/fetch + # initial properties + on_attach_renderer = None + + def __init__(self, attribute, title=None, data_type=None, **kwargs): + """ + Creates a new Column, which describes how a column in a + ObjectList should be rendered. + + @param attribute: a string with the name of the instance attribute the + column represents. + @param title: the title of the column, defaulting to the capitalized + form of the attribute. + @param data_type: the type of the attribute that will be inserted + into the column. + + @keyword visible: a boolean specifying if it is initially hidden or + shown. + @keyword justify: one of gtk.JUSTIFY_LEFT, gtk.JUSTIFY_RIGHT or + gtk.JUSTIFY_CENTER or None. If None, the justification will be + determined by the type of the attribute value of the first + instance to be inserted in the ObjectList (numbers will be + right-aligned). + @keyword format: a format string to be applied to the attribute + value upon insertion in the list. + @keyword width: the width in pixels of the column, if not set, uses the + default to ObjectList. If no Column specifies a width, + columns_autosize() will be called on the ObjectList upon append() + or the first add_list(). + @keyword sorted: whether or not the ObjectList is to be sorted by this + column. + If no Columns are sorted, the ObjectList will be created unsorted. + @keyword order: one of gtk.SORT_ASCENDING or gtk.SORT_DESCENDING or + -1. The value -1 is used internally when the column is not sorted. + @keyword expand: if set column will expand. Note: this space is shared + equally amongst all columns that have the expand set to True. + @keyword tooltip: a string which will be used as a tooltip for + the column header + @keyword format_func: a callable which will be used to format + the output of a column. The function will take one argument + which is the value to convert and is expected to return a string. + Note that you cannot use format and format_func at the same time, + if you provide a format function you'll be responsible for + converting the value to a string. + @keyword editable: if true the field is editable and when you modify + the contents of the cell the model will be updated. + @keyword searchable: if true the attribute values of the column can + be searched using type ahead search. Only string attributes are + currently supported. + @keyword radio: If true render the column as a radio instead of toggle. + Only applicable for columns with boolean data types. + @keyword cache: If true, the value will only be fetched once, + and the same value will be reused for futher access. + @keyword use_stock: If true, this will be rendered as pixbuf from the + value which should be a stock id. + @keyword icon_size: a gtk.IconSize constant, gtk.ICON_SIZE_MENU if not + specified. + @keyword editable_attribute: a string which is the attribute + which should decide if the cell is editable or not. + @keyword title_pixmap: (TODO) if set to a filename a pixmap will be + used *instead* of the title set. The title string will still be + used to identify the column in the column selection and in a + tooltip, if a tooltip is not set. + """ + + # XXX: filter function? + if ' ' in attribute: + msg = ("The attribute can not contain spaces, otherwise I can" + " not find the value in the instances: %s" % attribute) + raise AttributeError(msg) + + self.attribute = attribute + self.compare = None + self.from_string = None + + kwargs['title'] = title or attribute.capitalize() + if not data_type: + data_type = str + kwargs['data_type'] = data_type + + # If we don't specify a justification, right align it for int/float + # center for bools and left align it for everything else. + if "justify" not in kwargs: + if data_type: + if issubclass(data_type, bool): + kwargs['justify'] = gtk.JUSTIFY_CENTER + elif issubclass(data_type, (number, currency)): + kwargs['justify'] = gtk.JUSTIFY_RIGHT + + format_func = kwargs.get('format_func') + if format_func: + if not callable(format_func): + raise TypeError("format_func must be callable") + if 'format' in kwargs: + raise TypeError( + "format and format_func can not be used at the same time") + + # editable_attribute always turns on editable + if 'editable_attribute' in kwargs: + if not kwargs.get('editable', True): + raise TypeError( + "editable cannot be disabled when using editable_attribute") + kwargs['editable'] = True + + PropertyObject.__init__(self, **kwargs) + gobject.GObject.__init__(self) + + # This is meant to be subclassable, we're using kgetattr, as + # a staticmethod as an optimization, so we can avoid a function call. + get_attribute = staticmethod(kgetattr) + + def prop_set_data_type(self, data): + if data is not None: + conv = converter.get_converter(data) + self.compare = conv.get_compare_function() + self.from_string = conv.from_string + return data + + def __repr__(self): + namespace = self.__dict__.copy() + attr = namespace['attribute'] + del namespace['attribute'] + return "<%s %s: %s>" % (self.__class__.__name__, attr, namespace) + + # XXX: Replace these two with a gazpacho loader adapter + def __str__(self): + if self.data_type is None: + data_type = '' + else: + data_type = self.data_type.__name__ + + return "%s|%s|%s|%s|%d|%s|%s|%d|%s|%d" % \ + (self.attribute, self.title, data_type, self.visible, + self.justify, self.tooltip, self.format, self.width, + self.sorted, self.order) + + def as_string(self, data): + data_type = self.data_type + if (self.format or + data_type == datetime.date or + data_type == datetime.datetime or + data_type == datetime.time): + conv = converter.get_converter(data_type) + text = conv.as_string(data, format=self.format or None) + elif self.format_func: + text = self.format_func(data) + else: + text = data + + return text + + def from_string(cls, data_string): + fields = data_string.split('|') + if len(fields) != 10: + msg = 'every column should have 10 fields, not %d' % len(fields) + raise ValueError(msg) + + # the attribute is mandatory + if not fields[0]: + raise TypeError + + column = cls(fields[0]) + column.title = fields[1] or '' + column.data_type = str2type(fields[2]) + column.visible = str2bool(fields[3]) + column.justify = str2enum(fields[4], gtk.JUSTIFY_LEFT) + column.tooltip = fields[5] + column.format = fields[6] + + try: + column.width = int(fields[7]) + except ValueError: + pass + + column.sorted = str2bool(fields[8]) + column.order = str2enum(fields[9], gtk.SORT_ASCENDING) \ + or gtk.SORT_ASCENDING + + # XXX: expand, remember to sync with __str__ + + return column + from_string = classmethod(from_string) + +class SequentialColumn(Column): + """I am a column which will display a sequence of numbers, which + represent the row number. The value is independent of the data in + the other columns, so no matter what I will always display 1 in + the first column, unless you reverse it by clicking on the column + header. + + If you don't give me any argument I'll have the title of a hash (#) and + right justify the sequences.""" + def __init__(self, title='#', justify=gtk.JUSTIFY_RIGHT, **kwargs): + Column.__init__(self, '_kiwi_sequence_id', + title=title, justify=justify, data_type=int, **kwargs) + + def cell_data_func(self, tree_column, renderer, model, treeiter, + (column, renderer_prop)): + reversed = tree_column.get_sort_order() == gtk.SORT_DESCENDING + + row = model[treeiter] + if reversed: + sequence_id = len(model) - row.path[0] + else: + sequence_id = row.path[0] + 1 + + row[COL_MODEL]._kiwi_sequence_id = sequence_id + + try: + renderer.set_property(renderer_prop, sequence_id) + except TypeError: + raise TypeError("%r does not support parameter %s" % + (renderer, renderer_prop)) + +class ColoredColumn(Column): + """ + I am a column which can colorize the text of columns under + certain circumstances. I take a color and an extra function + which will be called for each row + + Example, to colorize negative values to red: + + >>> def colorize(value): + ... return value < 0 + ... + ... ColoredColumn('age', data_type=int, color='red', + ... data_func=colorize), + """ + + def __init__(self, attribute, title=None, data_type=None, + color=None, data_func=None, **kwargs): + if not issubclass(data_type, number): + raise TypeError("data type must be a number") + if not callable(data_func): + raise TypeError("data func must be callable") + + self._color = gdk.color_parse(color) + self._color_normal = None + + self._data_func = data_func + + Column.__init__(self, attribute, title, data_type, **kwargs) + + def on_attach_renderer(self, renderer): + renderer.set_property('foreground-set', True) + self._color_normal = renderer.get_property('foreground-gdk') + + def renderer_func(self, renderer, data): + if self._data_func(data): + color = self._color + else: + color = self._color_normal + + renderer.set_property('foreground-gdk', color) + +class _ContextMenu(gtk.Menu): + + """ + ContextMenu is a wrapper for the menu that's displayed when right + clicking on a column header. It monitors the treeview and rebuilds + when columns are added, removed or moved. + """ + + def __init__(self, treeview): + gtk.Menu.__init__(self) + + self._dirty = True + self._signal_ids = [] + self._treeview = treeview + self._treeview.connect('columns-changed', + self._on_treeview__columns_changed) + self._create() + + def clean(self): + for child in self.get_children(): + self.remove(child) + + for menuitem, signal_id in self._signal_ids: + menuitem.disconnect(signal_id) + self._signal_ids = [] + + def popup(self, event): + self._create() + gtk.Menu.popup(self, None, None, None, + event.button, event.time) + + def _create(self): + if not self._dirty: + return + + self.clean() + + for column in self._treeview.get_columns(): + header_widget = column.get_widget() + if not header_widget: + continue + title = header_widget.get_text() + + menuitem = gtk.CheckMenuItem(title) + menuitem.set_active(column.get_visible()) + signal_id = menuitem.connect("activate", + self._on_menuitem__activate, + column) + self._signal_ids.append((menuitem, signal_id)) + menuitem.show() + self.append(menuitem) + + self._dirty = False + + def _on_treeview__columns_changed(self, treeview): + self._dirty = True + + def _on_menuitem__activate(self, menuitem, column): + active = menuitem.get_active() + column.set_visible(active) + + # The width or height of some of the rows might have + # changed after changing the visibility of the column, + # so we have to re-measure all the rows, this can be done + # using row_changed. + model = self._treeview.get_model() + for row in model: + model.row_changed(row.path, row.iter) + + children = self.get_children() + if active: + # Make sure all items are selectable + for child in children: + child.set_sensitive(True) + else: + # Protect so we can't hide all the menu items + # If there's only one menuitem less to select, set + # it to insensitive + active_children = [child for child in children + if child.get_active()] + if len(active_children) == 1: + active_children[0].set_sensitive(False) + +COL_MODEL = 0 + +_marker = object() + +class ObjectList(PropertyObject, gtk.ScrolledWindow): + """ + An enhanced version of GtkTreeView, which provides pythonic wrappers + for accessing rows, and optional facilities for column sorting (with + types) and column selection. + + Items in an ObjectList is stored in objects. Each row represents an object + and each column represents an attribute in the object. + The column description object must be a subclass of L{Column}. + Simple example + + >>> class Fruit: + >>> pass + + >>> apple = Fruit() + >>> apple.name = 'Apple' + >>> apple.description = 'Worm house' + + >>> banana = Fruit() + >>> banana.name = 'Banana' + >>> banana.description = 'Monkey food' + + >>> list = ObjectList([Column('name'), + >>> Column('description')]) + >>> list.append(apple) + >>> list.append(banana) + """ + + __gtype_name__ = 'ObjectList' + + # row activated + gsignal('row-activated', object) + + # selected row(s) + gsignal('selection-changed', object) + + # row double-clicked + gsignal('double-click', object) + + # edited object, attribute name + gsignal('cell-edited', object, str) + + # emitted when empty or non-empty status changes + gsignal('has-rows', bool) + + # this property is used to serialize the columns of a ObjectList. The format + # is a big string with '^' as the column separator and '|' as the field + # separator + gproperty('column-definitions', str, nick="ColumnDefinitions") + gproperty('selection-mode', gtk.SelectionMode, + default=gtk.SELECTION_BROWSE, nick="SelectionMode") + + def __init__(self, columns=[], + instance_list=None, + mode=gtk.SELECTION_BROWSE): + """ + @param columns: a list of L{Column}s + @param instance_list: a list of objects to be inserted or None + @param mode: selection mode + """ + # allow to specify only one column + if isinstance(columns, Column): + columns = [columns] + elif not isinstance(columns, list): + raise TypeError("columns must be a list or a Column") + + if not isinstance(mode, gtk.SelectionMode): + raise TypeError("mode must be an gtk.SelectionMode enum") + # gtk.SELECTION_EXTENDED & gtk.SELECTION_MULTIPLE are both 3. + # so we can't do this check. + #elif mode == gtk.SELECTION_EXTENDED: + # raise TypeError("gtk.SELECTION_EXTENDED is deprecated") + + # Mapping of instance id -> treeiter + self._iters = {} + self._cell_data_caches = {} + self._columns_configured = False + self._autosize = True + self._vscrollbar = None + # by default we are unordered. This index points to the column + # definition of the column that dictates the order, in case there is + # any + self._sort_column_index = -1 + + gtk.ScrolledWindow.__init__(self) + + # we always want a vertical scrollbar. Otherwise the button on top + # of it doesn't make sense. This button is used to display the popup + # menu + self.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_ALWAYS) + self.set_shadow_type(gtk.SHADOW_ETCHED_IN) + + self._model = gtk.ListStore(object) + self._model.connect('row-inserted', self._on_model__row_inserted) + self._model.connect('row-deleted', self._on_model__row_deleted) + self._treeview = gtk.TreeView(self._model) + self._treeview.connect('button-press-event', + self._on_treeview__button_press_event) + self._treeview.connect_after('row-activated', + self._after_treeview__row_activated) + self._treeview.set_rules_hint(True) + self._treeview.show() + self.add(self._treeview) + + # these tooltips are used for the columns + self._tooltips = gtk.Tooltips() + + # create a popup menu for showing or hiding columns + self._popup = _ContextMenu(self._treeview) + + # when setting the column definition the columns are created + self.set_columns(columns) + + if instance_list: + self._treeview.freeze_notify() + self._load(instance_list, clear=True) + self._treeview.thaw_notify() + + if self._sort_column_index != -1: + column = self._columns[self._sort_column_index] + self._model.set_sort_column_id(self._sort_column_index, + column.order) + + # Set selection mode last to avoid spurious events + selection = self._treeview.get_selection() + selection.connect("changed", self._on_selection__changed) + + # Select the first item if no items are selected + if mode != gtk.SELECTION_NONE and instance_list: + selection.select_iter(self._model[COL_MODEL].iter) + + # Depends on treeview and selection being set up + PropertyObject.__init__(self) + + self.set_selection_mode(mode) + + # Python list object implementation + # These methods makes the kiwi list behave more or less + # like a normal python list + # + # TODO: + # operators + # __add__, __eq__, __ge__, __gt__, __iadd__, + # __imul__, __le__, __lt__, __mul__, __ne__, + # __rmul__ + # + # misc + # __delitem__, __hash__, __reduce__, __reduce_ex__ + # __reversed__ + + def __len__(self): + "len(list)" + return len(self._model) + + def __nonzero__(self): + "if list" + return True + + def __contains__(self, instance): + "item in list" + for row in self._model: + if row[COL_MODEL] == instance: + return True + return False + + def __iter__(self): + "for item in list" + class ModelIterator: + def __init__(self): + self._index = -1 + + def __iter__(self): + return self + + def next(self, model=self._model): + try: + self._index += 1 + return model[self._index][COL_MODEL] + except IndexError: + raise StopIteration + + return ModelIterator() + + def __getitem__(self, arg): + "list[n]" + if isinstance(arg, (int, gtk.TreeIter, str)): + item = self._model[arg][COL_MODEL] + elif isinstance(arg, slice): + model = self._model + return [model[item][COL_MODEL] + for item in slicerange(arg, len(self._model))] + else: + raise TypeError("argument arg must be int, gtk.Treeiter or " + "slice, not %s" % type(arg)) + return item + + def __setitem__(self, arg, item): + "list[n] = m" + if isinstance(arg, (int, gtk.TreeIter, str)): + self._model[arg] = (item,) + elif isinstance(arg, slice): + raise NotImplementedError("slices for list are not implemented") + else: + raise TypeError("argument arg must be int or gtk.Treeiter," + " not %s" % type(arg)) + + # append and remove are below + + def extend(self, iterable): + """ + Extend list by appending elements from the iterable + + @param iterable: + """ + + return self.add_list(iterable, clear=False) + + def index(self, item, start=None, stop=None): + """ + Return first index of value + + @param item: + @param start: + @param stop + """ + + if start is not None or stop is not None: + raise NotImplementedError("start and stop") + + treeiter = self._iters.get(id(item), _marker) + if treeiter is _marker: + raise ValueError("item %r is not in the list" % item) + + return self._model[treeiter].path[0] + + def count(self, item): + "L.count(item) -> integer -- return number of occurrences of value" + + count = 0 + for row in self._model: + if row[COL_MODEL] == item: + count += 1 + return count + + def insert(self, index, item): + "L.insert(index, item) -- insert object before index" + raise NotImplementedError + + def pop(self, index): + """ + Remove and return item at index (default last) + @param index: + """ + raise NotImplementedError + + def reverse(self, pos, item): + "L.reverse() -- reverse *IN PLACE*" + raise NotImplementedError + + def sort(self, pos, item): + """L.sort(cmp=None, key=None, reverse=False) -- stable sort *IN PLACE*; + cmp(x, y) -> -1, 0, 1""" + raise NotImplementedError + + # Properties + + def prop_set_column_definition(self, value): + self.set_columns(value) + return value + + def prop_set_selection_mode(self, mode): + self.set_selection_mode(mode) + + def prop_get_selection_mode(self): + return self.get_selection_mode() + + # Model + + def _on_model__row_inserted(self, model, path, iter): + if len(model) == 1: + self.emit('has-rows', True) + + def _on_model__row_deleted(self, model, path): + if not len(model): + self.emit('has-rows', False) + + # Columns handling + def _load(self, instances, clear): + # do nothing if empty list or None provided + model = self._model + if clear: + if not instances: + self.unselect_all() + self.clear() + return + + model = self._model + iters = self._iters + + old_instances = [row[COL_MODEL] for row in model] + + # Save selection + selected_instances = [] + if old_instances: + selection = self._treeview.get_selection() + _, paths = selection.get_selected_rows() + if paths: + selected_instances = [model[path][COL_MODEL] + for (path,) in paths] + + iters = self._iters + prev = None + # Do not always just clear the list, check if we have the same + # instances in the list we want to insert and merge in the new + # items + if clear: + for instance in iter(instances): + objid = id(instance) + # If the instance is not in the list insert it after + # the previous inserted object + if not objid in iters: + if prev is None: + prev = model.append((instance,)) + else: + prev = model.insert_after(prev, (instance,)) + iters[objid] = prev + else: + prev = iters[objid] + + # Optimization when we were empty, we wont need to remove anything + # nor restore selection + if old_instances: + # Remove + objids = [id(instance) for instance in instances] + for instance in old_instances: + objid = id(instance) + if objid in objids: + continue + self._remove(objid) + else: + for instance in iter(instances): + iters[id(instance)] = model.append((instance,)) + + # Restore selection + for instance in selected_instances: + objid = id(instance) + if objid in iters: + selection.select_iter(iters[objid]) + + # As soon as we have data for that list, we can autosize it, and + # we don't want to autosize again, or we may cancel user + # modifications. + if self._autosize: + self._treeview.columns_autosize() + self._autosize = False + + def _setup_columns(self): + if self._columns_configured: + return + + searchable = None + sorted = None + expand = False + for column in self._columns: + if column.searchable: + if searchable: + raise ValueError("Can't make column %s searchable, column" + " %s is already set as searchable" % ( + column.attribute, searchable.attribute)) + searchable = column.searchable + if column.sorted: + if sorted: + raise ValueError("Can't make column %s sorted, column" + " %s is already set as sortable" % ( + column.attribute, sorted.attribute)) + sorted = column.sorted + if column.expand: + expand = True + + for column in self._columns: + self._setup_column(column) + + if not expand: + column = gtk.TreeViewColumn() + self._treeview.append_column(column) + + self._columns_configured = True + + def _setup_column(self, column): + # You can't subclass bool, so this is okay + if (column.data_type is bool and column.format): + raise TypeError("format is not supported for boolean columns") + + index = self._columns.index(column) + self._model.set_sort_func(index, self._sort_function) + treeview_column = self._treeview.get_column(index) + if treeview_column is None: + treeview_column = self._create_column(column) + + renderer, renderer_prop = self._guess_renderer_for_type(column) + if column.on_attach_renderer: + column.on_attach_renderer(renderer) + justify = column.justify + if justify == gtk.JUSTIFY_RIGHT: + xalign = 1.0 + elif justify == gtk.JUSTIFY_CENTER: + xalign = 0.5 + elif justify in (gtk.JUSTIFY_LEFT, + gtk.JUSTIFY_FILL): + xalign = 0.0 + else: + raise AssertionError + renderer.set_property("xalign", xalign) + treeview_column.set_property("alignment", xalign) + + if column.use_stock: + cell_data_func = self._cell_data_pixbuf_func + else: + cell_data_func = self._cell_data_text_func + + if column.cell_data_func: + cell_data_func = column.cell_data_func + elif column.cache: + self._cell_data_caches[column.attribute] = {} + + treeview_column.pack_start(renderer) + treeview_column.set_cell_data_func(renderer, cell_data_func, + (column, renderer_prop)) + treeview_column.set_visible(column.visible) + + treeview_column.connect("clicked", self._on_column__clicked, column) + if column.width: + treeview_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) + treeview_column.set_fixed_width(column.width) + if column.tooltip: + widget = self._get_column_button(treeview_column) + if widget is not None: + self._tooltips.set_tip(widget, column.tooltip) + + if column.expand: + # Default is False + treeview_column.set_expand(True) + + if column.sorted: + self._sort_column_index = index + treeview_column.set_sort_indicator(True) + + if column.width: + self._autosize = False + + if column.searchable: + if not issubclass(column.data_type, basestring): + raise TypeError("Unsupported data type for " + "searchable column: %s" % column.data_type) + self._treeview.set_search_column(index) + self._treeview.set_search_equal_func(self._search_equal_func, + column) + + if column.radio: + if not issubclass(column.data_type, bool): + raise TypeError("You can only use radio for boolean columns") + + # typelist here may be none. It's okay; justify_columns will try + # and use the specified justifications and if not present will + # not touch the column. When typelist is not set, + # append/add_list have a chance to fix up the remaining + # justification by looking at the first instance's data. +# self._justify_columns(columns, typelist) + + def _create_column(self, column): + treeview_column = gtk.TreeViewColumn() + # we need to set our own widget because otherwise + # __get_column_button won't work + + label = gtk.Label(column.title) + label.show() + treeview_column.set_widget(label) + treeview_column.set_resizable(True) + treeview_column.set_clickable(True) + treeview_column.set_reorderable(True) + self._treeview.append_column(treeview_column) + + # setup the button to show the popup menu + button = self._get_column_button(treeview_column) + button.connect('button-release-event', + self._on_header__button_release_event) + return treeview_column + + def _on_renderer_toggle_check__toggled(self, renderer, path, model, attr): + obj = model[path][COL_MODEL] + value = not getattr(obj, attr, None) + setattr(obj, attr, value) + self.emit('cell-edited', obj, attr) + + def _on_renderer_toggle_radio__toggled(self, renderer, path, model, attr): + # Deactive old one + old = renderer.get_data('kiwilist::radio-active') + + # If we don't have the radio-active set it means we're doing + # This for the first time, so scan and see which one is currently + # active, so we can deselect it + if not old: + # XXX: Handle multiple values set to True, this + # algorithm just takes the first one it finds + for row in self._model: + obj = row[COL_MODEL] + value = getattr(obj, attr) + if value == True: + old = obj + break + else: + raise TypeError("You need an initial attribute value set " + "to true when using radio") + + setattr(old, attr, False) + + # Active new and save a reference to the object of the + # previously selected row + new = model[path][COL_MODEL] + setattr(new, attr, True) + renderer.set_data('kiwilist::radio-active', new) + self.emit('cell-edited', new, attr) + + def _on_renderer_text__edited(self, renderer, path, text, + model, attr, column, from_string): + obj = model[path][COL_MODEL] + value = from_string(text) + setattr(obj, attr, value) + self.emit('cell-edited', obj, attr) + + def _guess_renderer_for_type(self, column): + """Gusses which CellRenderer we should use for a given type. + It also set the property of the renderer that depends on the model, + in the renderer. + """ + + # TODO: Move to column + data_type = column.data_type + if data_type is bool: + renderer = gtk.CellRendererToggle() + if column.editable: + renderer.set_property('activatable', True) + # Boolean can be either a radio or a checkbox. + # Changes are handled by the toggled callback, which + # should only be connected if the column is editable. + if column.radio: + renderer.set_radio(True) + cb = self._on_renderer_toggle_radio__toggled + else: + cb = self._on_renderer_toggle_check__toggled + renderer.connect('toggled', cb, self._model, column.attribute) + prop = 'active' + elif column.use_stock or data_type == gdk.Pixbuf: + renderer = gtk.CellRendererPixbuf() + prop = 'pixbuf' + if column.editable: + raise TypeError("use-stock columns cannot be editable") + elif issubclass(data_type, (datetime.date, datetime.time, + basestring, number, + currency)): + renderer = gtk.CellRendererText() + prop = 'text' + if column.editable: + renderer.set_property('editable', True) + renderer.connect('edited', self._on_renderer_text__edited, + self._model, column.attribute, column, + column.from_string) + + else: + raise ValueError("the type %s is not supported yet" % data_type) + + return renderer, prop + + def _search_equal_func(self, model, tree_column, key, treeiter, column): + data = column.get_attribute(model[treeiter][COL_MODEL], + column.attribute, None) + if data.startswith(key): + return False + return True + + def _cell_data_text_func(self, tree_column, renderer, model, treeiter, + (column, renderer_prop)): + + row = model[treeiter] + if column.editable_attribute: + data = column.get_attribute(row[COL_MODEL], + column.editable_attribute, None) + data_type = column.data_type + if isinstance(renderer, gtk.CellRendererToggle): + renderer.set_property('activatable', data) + elif isinstance(renderer, gtk.CellRendererText): + renderer.set_property('editable', data) + else: + raise AssertionError + + if column.cache: + cache = self._cell_data_caches[column.attribute] + path = row.path[0] + if path in cache: + data = cache[path] + else: + data = column.get_attribute(row[COL_MODEL], + column.attribute, None) + cache[path] = data + else: + data = column.get_attribute(row[COL_MODEL], + column.attribute, None) + + text = column.as_string(data) + + renderer.set_property(renderer_prop, text) + + if column.renderer_func: + column.renderer_func(renderer, data) + + def _cell_data_pixbuf_func(self, tree_column, renderer, model, treeiter, + (column, renderer_prop)): + row = model[treeiter] + data = column.get_attribute(row[COL_MODEL], + column.attribute, None) + pixbuf = self.render_icon(data, column.icon_size) + renderer.set_property(renderer_prop, pixbuf) + + def _on_header__button_release_event(self, button, event): + if event.button == 3: + self._popup.popup(event) + return False + + return False + + def _on_renderer__edited(self, renderer, path, value, column): + data_type = column.data_type + if data_type in number: + value = data_type(value) + + # XXX convert new_text to the proper data type + setattr(self._model[path][COL_MODEL], column.attribute, value) + + def _on_renderer__toggled(self, renderer, path, column): + setattr(self._model[path][COL_MODEL], column.attribute, + not renderer.get_active()) + + def _clear_columns(self): + while self._treeview.get_columns(): + self._treeview.remove_column(self._treeview.get_column(COL_MODEL)) + + self._popup.clean() + + self._columns_configured = False + + # selection methods + def _select_and_focus_row(self, row_iter): + self._treeview.set_cursor(self._model[row_iter].path) + + def _sort_function(self, model, iter1, iter2): + column = self._columns[self._sort_column_index] + attr = column.attribute + return column.compare( + column.get_attribute(model[iter1][COL_MODEL], attr), + column.get_attribute(model[iter2][COL_MODEL], attr)) + + def _on_column__clicked(self, treeview_column, column): + # this means we are not sorting at all + if self._sort_column_index == -1: + return + + old_treeview_column = self._treeview.get_column( + self._sort_column_index) + + if treeview_column is old_treeview_column: + # same column, so reverse the order + if column.order == gtk.SORT_ASCENDING: + new_order = gtk.SORT_DESCENDING + elif column.order == gtk.SORT_DESCENDING: + new_order = gtk.SORT_ASCENDING + else: + raise AssertionError + else: + # new column, sort ascending + new_order = gtk.SORT_ASCENDING + self._sort_column_index = self._columns.index(column) + # cosmetic changes + old_treeview_column.set_sort_indicator(False) + treeview_column.set_sort_indicator(True) + + column.order = new_order + treeview_column.set_sort_order(new_order) + + # This performs the actual ordering + self._model.set_sort_column_id(self._sort_column_index, new_order) + + # handlers + def _after_treeview__row_activated(self, treeview, path, view_column): + try: + row = self._model[path] + except IndexError: + print 'path %s was not found in model: %s' % ( + path, map(list, self._model)) + return + item = row[COL_MODEL] + self.emit('row-activated', item) + + def _on_selection__changed(self, selection): + mode = selection.get_mode() + if mode == gtk.SELECTION_MULTIPLE: + item = self.get_selected_rows() + elif mode in (gtk.SELECTION_SINGLE, gtk.SELECTION_BROWSE): + item = self.get_selected() + else: + raise AssertionError + self.emit('selection-changed', item) + + def _on_treeview__button_press_event(self, treeview, event): + if event.type == gtk.gdk._2BUTTON_PRESS: + selection = self._treeview.get_selection() + mode = selection.get_mode() + if mode == gtk.SELECTION_MULTIPLE: + item = self.get_selected_rows() + else: + item = self.get_selected() + self.emit('double-click', item) + + # hacks + def _get_column_button(self, column): + """Return the button widget of a particular TreeViewColumn. + + This hack is needed since that widget is private of the TreeView but + we need access to them for Tooltips, right click menus, ... + + Use this function at your own risk + """ + + button = column.get_widget() + assert button is not None, ("You must call column.set_widget() " + "before calling _get_column_button") + + while not isinstance(button, gtk.Button): + button = button.get_parent() + + return button + + # start of the hack to put a button on top of the vertical scrollbar + def _setup_popup_button(self): + """Put a button on top of the vertical scrollbar to show the popup + menu. + Internally it uses a POPUP window so you can tell how *Evil* is this. + """ + self._popup_window = gtk.Window(gtk.WINDOW_POPUP) + self._popup_button = gtk.Button('*') + self._popup_window.add(self._popup_button) + self._popup_window.show_all() + + self.forall(self._find_vertical_scrollbar) + self.connect('size-allocate', self._on_scrolled_window__size_allocate) + self.connect('realize', self._on_scrolled_window__realize) + + def _find_vertical_scrollbar(self, widget): + """This method is called from a .forall() method in the ScrolledWindow. + It just save a reference to the vertical scrollbar for doing evil + things later. + """ + if isinstance(widget, gtk.VScrollbar): + self._vscrollbar = widget + + def _get_header_height(self): + treeview_column = self._treeview.get_column(0) + button = self._get_column_button(treeview_column) + alloc = button.get_allocation() + return alloc.height + + def _on_scrolled_window__realize(self, widget): + toplevel = widget.get_toplevel() + self._popup_window.set_transient_for(toplevel) + self._popup_window.set_destroy_with_parent(True) + + def _on_scrolled_window__size_allocate(self, widget, allocation): + """Resize the Vertical Scrollbar to make it smaller and let space + for the popup button. Also put that button there. + """ + old_alloc = self._vscrollbar.get_allocation() + height = self._get_header_height() + new_alloc = gtk.gdk.Rectangle(old_alloc.x, old_alloc.y + height, + old_alloc.width, + old_alloc.height - height) + self._vscrollbar.size_allocate(new_alloc) + # put the popup_window in its position + gdk_window = self.window + if gdk_window: + winx, winy = gdk_window.get_origin() + self._popup_window.move(winx + old_alloc.x, + winy + old_alloc.y) + + # end of the popup button hack + + # + # Public API + # + def get_model(self): + "Return treemodel of the current list" + return self._model + + def get_treeview(self): + "Return treeview of the current list" + return self._treeview + + def get_columns(self): + return self._columns + + def get_column_by_name(self, name): + """Returns the name of a column""" + for column in self._columns: + if column.attribute == name: + return column + + raise LookupError("There is no column called %s" % name) + + def get_treeview_column(self, column): + """ + @param column: a @Column + """ + if not isinstance(column, Column): + raise TypeError + + if not column in self._columns: + raise ValueError + + index = self._columns.index(column) + tree_columns = self._treeview.get_columns() + return tree_columns[index] + + def set_columns(self, value): + """This function can be called in two different ways: + - value is a string with the column definitions in a special format + (see column-definitions property at the beginning of this class) + + - value is a list/tuple of Column objects + """ + + if isinstance(value, basestring): + self._columns_string = value + self._columns = [] + for col in value.split('^'): + if not col: + continue + self._columns.append(Column.from_string(col)) + elif isinstance(value, (list, tuple)): + self._columns = value + self._columns_string = '^'.join([str(col) for col in value]) + else: + raise ValueError("value should be a string of a list of columns") + + self._clear_columns() + self._setup_columns() + + def append(self, instance, select=False): + """Adds an instance to the list. + - instance: the instance to be added (according to the columns spec) + - select: whether or not the new item should appear selected. + """ + + # Freeze and save original selection mode to avoid blinking + self._treeview.freeze_notify() + + row_iter = self._model.append((instance,)) + self._iters[id(instance)] = row_iter + + if self._autosize: + self._treeview.columns_autosize() + + if select: + self._select_and_focus_row(row_iter) + self._treeview.thaw_notify() + + def _remove(self, objid): + # linear search for the instance to remove + treeiter = self._iters.pop(objid) + if not treeiter: + return False + + # Remove any references to this path + self._clear_cache_for_iter(treeiter) + + # All references to the iter gone, now it can be removed + self._model.remove(treeiter) + + return True + + def _clear_cache_for_iter(self, treeiter): + # Not as inefficent as it looks + path = self._model[treeiter].path[0] + for cache in self._cell_data_caches.values(): + if path in cache: + del cache[path] + + def remove(self, instance, select=False): + """Remove an instance from the list. + If the instance is not in the list it returns False. Otherwise it + returns True. + + @param instance: + @param select: if true, the previous item will be selected + if there is one. + """ + + objid = id(instance) + if not objid in self._iters: + raise ValueError("instance %r is not in the list" % instance) + + + if select: + prev = self.get_previous(instance) + rv = self._remove(objid) + if prev != instance: + self.select(prev) + else: + rv = self._remove(objid) + return rv + + def update(self, instance): + objid = id(instance) + if not objid in self._iters: + raise ValueError("instance %r is not in the list" % instance) + treeiter = self._iters[objid] + self._clear_cache_for_iter(treeiter) + self._model.row_changed(self._model[treeiter].path, treeiter) + + def refresh(self): + """ + Reloads the values from all objects. + """ + # XXX: Optimize this to only reload items, no need to remove/readd + model = self._model + instances = [row[COL_MODEL] for row in model] + model.clear() + self.add_list(instances) + + def set_column_visibility(self, column_index, visibility): + treeview_column = self._treeview.get_column(column_index) + treeview_column.set_visible(visibility) + + def get_selection_mode(self): + selection = self._treeview.get_selection() + if selection: + return selection.get_mode() + + def set_selection_mode(self, mode): + selection = self._treeview.get_selection() + if selection: + self.notify('selection-mode') + return selection.set_mode(mode) + + def unselect_all(self): + selection = self._treeview.get_selection() + if selection: + selection.unselect_all() + + def select_paths(self, paths): + """ + Selects a number of rows corresponding to paths + + @param paths: rows to be selected + """ + + selection = self._treeview.get_selection() + if selection.get_mode() == gtk.SELECTION_NONE: + raise TypeError("Selection not allowed") + + selection.unselect_all() + for path in paths: + selection.select_path(path) + + def select(self, instance, scroll=True): + selection = self._treeview.get_selection() + if selection.get_mode() == gtk.SELECTION_NONE: + raise TypeError("Selection not allowed") + + objid = id(instance) + if not objid in self._iters: + raise ValueError("instance %s is not in the list" % repr(instance)) + + treeiter = self._iters[objid] + + selection.select_iter(treeiter) + + if scroll: + self._treeview.scroll_to_cell(self._model[treeiter].path, + None, True, 0.5, 0) + + def get_selected(self): + """Returns the currently selected object + If an object is not selected, None is returned + """ + selection = self._treeview.get_selection() + if not selection: + # AssertionError ? + return + + mode = selection.get_mode() + if mode == gtk.SELECTION_NONE: + raise TypeError("Selection not allowed in %r mode" % mode) + elif mode not in (gtk.SELECTION_SINGLE, gtk.SELECTION_BROWSE): + log.warn('get_selected() called when multiple rows ' + 'can be selected') + + model, treeiter = selection.get_selected() + if treeiter: + return model[treeiter][COL_MODEL] + + def get_selected_rows(self): + """Returns a list of currently selected objects + If no objects are selected an empty list is returned + """ + selection = self._treeview.get_selection() + if not selection: + # AssertionError ? + return + + mode = selection.get_mode() + if mode == gtk.SELECTION_NONE: + raise TypeError("Selection not allowed in %r mode" % mode) + elif mode in (gtk.SELECTION_SINGLE, gtk.SELECTION_BROWSE): + log.warn('get_selected_rows() called when only a single row ' + 'can be selected') + + model, paths = selection.get_selected_rows() + if paths: + return [model[path][COL_MODEL] for (path,) in paths] + return [] + + def add_list(self, instances, clear=True): + """ + Allows a list to be loaded, by default clearing it first. + freeze() and thaw() are called internally to avoid flashing. + + @param instances: a list to be added + @param clear: a boolean that specifies whether or not to + clear the list + """ + + self._treeview.freeze_notify() + + ret = self._load(instances, clear) + + self._treeview.thaw_notify() + + return ret + + def clear(self): + """Removes all the instances of the list""" + self._model.clear() + self._iters = {} + + # Don't clear the whole cache, just the + # individual column caches + for key in self._cell_data_caches: + self._cell_data_caches[key] = {} + + def get_next(self, instance): + """ + Returns the item after instance in the list. + Note that the instance must be inserted before this can be called + If there are no instances after, the first item will be returned. + + @param instance: the instance + """ + + objid = id(instance) + if not objid in self._iters: + raise ValueError("instance %r is not in the list" % instance) + + treeiter = self._iters[objid] + + model = self._model + pos = model[treeiter].path[0] + if pos >= len(model) - 1: + pos = 0 + else: + pos += 1 + return model[pos][COL_MODEL] + + def get_previous(self, instance): + """ + Returns the item before instance in the list. + Note that the instance must be inserted before this can be called + If there are no instances before, the last item will be returned. + + @param instance: the instance + """ + + objid = id(instance) + if not objid in self._iters: + raise ValueError("instance %r is not in the list" % instance) + treeiter = self._iters[objid] + + model = self._model + pos = model[treeiter].path[0] + if pos == 0: + pos = len(model) - 1 + else: + pos -= 1 + return model[pos][COL_MODEL] + + def get_selected_row_number(self): + """ + @returns: the selected row number or None if no rows were selected + """ + selection = self._treeview.get_selection() + if selection.get_mode() == gtk.SELECTION_MULTIPLE: + model, paths = selection.get_selected_rows() + if paths: + return paths[0][0] + else: + model, iter = selection.get_selected() + if iter: + return model[iter].path[0] + + def double_click(self, rowno): + """ + Same as double clicking on the row rowno + + @param rowno: integer + """ + columns = self._treeview.get_columns() + if not columns: + raise AssertionError( + "%s has no columns" % self.get_name()) + + self._treeview.row_activated(rowno, columns[0]) + + def set_headers_visible(self, value): + """ + @param value: if true, shows the headers, if false hide then + """ + self._treeview.set_headers_visible(value) + +type_register(ObjectList) + +class ListLabel(gtk.HBox): + """I am a subclass of a GtkHBox which you can use if you want + to vertically align a label with a column + """ + + def __init__(self, klist, column, label='', value_format='%s'): + """ + @param klist: list to follow + @type klist: kiwi.ui.objectlist.ObjectList + @param column: name of a column in a klist + @type column: string + @param label: label + @type label: string + @param value_format: format string used to format value + @type value_format: string + """ + self._label = label + self._label_width = -1 + if not isinstance(klist, ObjectList): + raise TypeError("list must be a kiwi list and not %r" % + type(klist).__name__) + self._klist = klist + if not isinstance(column, str): + raise TypeError("column must be a string and not %r" % + type(column).__name__) + self._column = klist.get_column_by_name(column) + self._value_format = value_format + + gtk.HBox.__init__(self) + + self._create_ui() + + # Public API + + def set_value(self, value): + """Sets the value of the label. + Note that it needs to be of the same type as you specified in + value_format in the constructor. + I also support the GMarkup syntax, so you can use "<b>%d</b>" if + you want.""" + self._value_widget.set_markup(self._value_format % value) + + def get_value_widget(self): + return self._value_widget + + def get_label_widget(self): + return self._label_widget + + # Private + + def _create_ui(self): + + # When tracking the position/size of a column, we need to pay + # attention to the following two things: + # * treeview_column::width + # * size-allocate of treeview_columns header widget + # + tree_column = self._klist.get_treeview_column(self._column) + tree_column.connect('notify::width', + self._on_treeview_column__notify_width) + + button = self._klist._get_column_button(tree_column) + button.connect('size-allocate', + self._on_treeview_column_button__size_allocate) + + self._label_widget = gtk.Label() + self._label_widget.set_markup(self._label) + + layout = self._label_widget.get_layout() + self._label_width = layout.get_pixel_size()[0] + self._label_widget.set_alignment(1.0, 0.5) + self.pack_start(self._label_widget, False, False, padding=6) + self._label_widget.show() + + self._value_widget = gtk.Label() + xalign = tree_column.get_property('alignment') + self._value_widget.set_alignment(xalign, 0.5) + self.pack_start(self._value_widget, False, False) + self._value_widget.show() + + def _resize(self, position=-1, width=-1): + if position != -1: + if position != 0: + if self._label_width > position: + self._label_widget.set_text('') + else: + self._label_widget.set_markup(self._label) + + # XXX: Replace 12 with a constant + if position >= 12: + self._label_widget.set_size_request(position - 12, -1) + + if width != -1: + self._value_widget.set_size_request(width, -1) + + # Callbacks + + def _on_treeview_column_button__size_allocate(self, label, rect): + self._resize(position=rect[0]) + + def _on_treeview_column__notify_width(self, treeview, pspec): + value = treeview.get_property(pspec.name) + self._resize(width=value) + + def _on_list__size_allocate(self, list, rect): + self._resize(position=rect[0], width=rect[2]) + + +class SummaryLabel(ListLabel): + """I am a subclass of ListLabel which you can use if you want + to summarize all the values of a specific column. + Please note that I only know how to handle number column + data types and I will complain if you give me something else.""" + + def __init__(self, klist, column, label=_('Total:'), value_format='%s'): + ListLabel.__init__(self, klist, column, label, value_format) + if not issubclass(self._column.data_type, number): + raise TypeError("data_type of column must be a number, not %r", + self._column.data_type) + klist.connect('cell-edited', self._on_klist__cell_edited) + self.update_total() + + # Public API + + def update_total(self): + """Recalculate the total value of all columns""" + column = self._column + attr = column.attribute + get_attribute = column.get_attribute + + value = sum([get_attribute(obj, attr) for obj in self._klist], + column.data_type('0')) + + self.set_value(column.as_string(value)) + + # Callbacks + + def _on_klist__cell_edited(self, klist, object, attribute): + self.update_total() diff --git a/kiwi/ui/proxy.py b/kiwi/ui/proxy.py new file mode 100644 index 0000000..fb80138 --- /dev/null +++ b/kiwi/ui/proxy.py @@ -0,0 +1,373 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2002-2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> +# + +"""This module defines the Proxy class, which is a facility that can be used +to keep the state of a model object synchronized with a View. +""" + +import gobject +import gtk + +from kiwi import ValueUnset +from kiwi.accessor import kgetattr, ksetattr, clear_attr_cache +from kiwi.decorators import deprecated +from kiwi.interfaces import IProxyWidget, IValidatableProxyWidget +from kiwi.log import Logger + +class ProxyError(Exception): + pass + +log = Logger('proxy') + +def block_widget(widget): + """Blocks the signal handler of the 'content-changed' signal on widget""" + connection_id = widget.get_data('content-changed-id') + if connection_id: + widget.handler_block(connection_id) + +def unblock_widget(widget): + """Unblocks the signal handler of the 'content-changed' signal on widget""" + connection_id = widget.get_data('content-changed-id') + if connection_id: + widget.handler_unblock(connection_id) + +class Proxy: + """ A Proxy is a class that 'attaches' an instance to an interface's + widgets, and transparently manipulates that instance's attributes as + the user alters the content of the widgets. + + The Proxy takes the widget list and detects what widgets are to be + attached to the model by looking if it is a KiwiWidget and if it + has the model-attribute set. + """ + + def __init__(self, view, model=None, widgets=()): + """ + @param view: view attched to the slave + @type view: a L{kiwi.ui.views.BaseView} subclass + @param model: model attached to proxy + @param widgets: the widget names + @type widgets: list of strings + """ + self._view = view + self._model = model + self._model_attributes = {} + + for widget_name in widgets: + widget = getattr(self._view, widget_name, None) + if widget is None: + raise AttributeError("The widget %s was not found in the " + "view %s" % ( + widget_name, self._view.__class__.__name__)) + + self._setup_widget(widget_name, widget) + + # Private API + + def _reset_widget(self, attribute, widget): + if self._model is None: + # if we have no model, leave value unset so we pick up + # the widget default below. + value = ValueUnset + else: + # if we have a model, grab its value to update the widgets + self._register_proxy_in_model(attribute) + value = kgetattr(self._model, attribute, ValueUnset) + + self.update(attribute, value, block=True) + + # The initial value of the model is set, at this point + # do a read, it'll trigger a validation for widgets who + # supports it. + if not IValidatableProxyWidget.providedBy(widget): + return + + widget.validate(force=True) + + def _setup_widget(self, widget_name, widget): + if not IProxyWidget.providedBy(widget): + raise ProxyError("The widget %s (%r), in view %s is not " + "a kiwi widget and cannot be added to a proxy" + % (widget_name, widget, + self._view.__class__.__name__)) + + data_type = widget.get_property('data-type') + if data_type is None: + raise ProxyError("The kiwi widget %s (%r) in view %s should " + "have a data type set" % ( + widget_name, widget, self._view.__class__.__name__)) + + attribute = widget.get_property('model-attribute') + if not attribute: + raise ProxyError( + "The widget %s (%s) in view %s is a kiwi widget but does " + "not have a model attribute set so it will not be " + "associated with the model" % ( + widget_name, widget, self._view.__class__.__name__)) + + # Do a isinstance here instead of in the callback, + # as an optimization, it'll never change in runtime anyway + connection_id = widget.connect( + 'content-changed', + self._on_widget__content_changed, + attribute, + IValidatableProxyWidget.providedBy(widget)) + widget.set_data('content-changed-id', connection_id) + + model_attributes = self._model_attributes + # save this widget in our map + if (attribute in model_attributes and + # RadioButtons are allowed several times + not gobject.type_is_a(widget, 'GtkRadioButton')): + old_widget = model_attributes[attribute] + raise KeyError("The widget %s (%r) in view %s is already in " + "the proxy, defined by widget %s (%r)" % ( + widget_name, widget, self._view.__class__.__name__, + old_widget.name, old_widget)) + + model_attributes[attribute] = widget + self._reset_widget(attribute, widget) + + def _register_proxy_in_model(self, attribute): + model = self._model + if not hasattr(model, "register_proxy_for_attribute"): + return + try: + model.register_proxy_for_attribute(attribute, self) + except AttributeError: + msg = ("Failed to run register_proxy() on Model %s " + "(that was supplied to %s. \n" + "(Hint: if this model also inherits from ZODB's " + "Persistent class, this problem occurs if you haven't " + "set __setstate__() up correctly. __setstate__() " + "should call Model.__init__() (and " + "Persistent.__setstate__() of course) to rereset " + "things properly.)") + raise TypeError(msg % (model, self)) + + def _unregister_proxy_in_model(self): + if self._model and hasattr(self._model, "unregister_proxy"): + self._model.unregister_proxy(self) + + # Callbacks + + def _on_widget__content_changed(self, widget, attribute, validate): + """This is called as soon as the content of one of the widget + changes, the widgets tries fairly hard to not emit when it's not + neccessary""" + + # skip updates for model if there is none, right? + if self._model is None: + return + + if validate: + value = widget.validate() + else: + value = widget.read() + + log('%s.%s = %r' % (self._model.__class__.__name__, + attribute, value)) + + # only update the model if the data is correct + if value is ValueUnset: + return + + model = self._model + # XXX: one day we might want to queue and unique updates? + if hasattr(model, "block_proxy"): + model.block_proxy(self) + ksetattr(model, attribute, value) + model.unblock_proxy(self) + else: + ksetattr(model, attribute, value) + + # Call global update hook + self.proxy_updated(widget, attribute, value) + + # Properties + + def _get_model(self): + return self._model + model = property(_get_model) + + # Public API + + def proxy_updated(self, widget, attribute, value): + """ This is a hook that is called whenever the proxy updates the + model. Implement it in the inherited class to perform actions that + should be done each time the user changes something in the interface. + This hook by default does nothing. + @param widget: + @param attribute: + @param value: + """ + + def update_many(self, attributes, value=ValueUnset, block=False): + """ + Like L{update} but takes a sequence of attributes + + @param attributes: sequence of attributes to update + @param value: see L{update} + @param block: see L{update} + """ + + for attribute in attributes: + self.update(attribute, value, block) + + def update(self, attribute, value=ValueUnset, block=False): + """ Generic frontend function to update the contentss of a widget based + on its model attribute name using the internal update functions. + + @param attribute: the name of the attribute whose widget we wish to + updated. If accessing a radiobutton, specify its group + name. + @param value: specifies the value to set in the widget. If + unspecified, it defaults to the current model's value + (through an accessor, if it exists, or getattr). + @param block: defines if we are to block cascading proxy updates + triggered by this update. You should use block if you are + calling update on *the same attribute that is currently + being updated*. + This means if you have hooked to a signal of the widget + associated to that attribute, and you call update() for + the *same attribute*, use block=True. And pray. 8). If + block is set to False, the normal update mechanism will + occur (the model being updated in the end, hopefully). + """ + + if value is ValueUnset: + # We want to obtain a value from our model + if self._model is None: + # We really want to avoid trying to update our UI if our + # model doesn't exist yet and no value was provided. + # update() is also called by user code, but it should be + # safe to return here since you shouldn't need to code + # around the lack of a model in your callbacks if you + # can help it. + value = None + else: + value = kgetattr(self._model, attribute, ValueUnset) + + widget = self._model_attributes.get(attribute, None) + + if widget is None: + raise AttributeError("Called update for `%s', which isn't " + "attached to the proxy %s. Valid " + "attributes are: %s (you may have " + "forgetten to add `:' to the name in " + "the widgets list)" + % (attribute, self, + self._model_attributes.keys())) + + + # The type of value should match the data-type property. The two + # exceptions to this rule are ValueUnset and None + if not (value is ValueUnset or value is None): + data_type = widget.get_property('data-type') + value_type = type(value) + if not isinstance(value, data_type): + raise TypeError( + "attribute %s of model %r requires a value of " + "type %s, not %s" % ( + attribute, self._model, + data_type.__name__, + value_type.__name__)) + + if block: + block_widget(widget) + self._view.handler_block(widget) + widget.update(value) + self._view.handler_unblock(widget) + unblock_widget(widget) + else: + widget.update(value) + return True + + def set_model(self, model, relax_type=False): + """ + Updates the model instance of the proxy. + Allows a proxy interface to change model without the need to destroy + and recreate the UI (which would cause flashing, at least) + + @param model: + @param relax_type: + """ + if self._model is not None and model is not None: + if (not relax_type and + type(model) != type(self._model) and + not isinstance(model, self._model.__class__)): + raise TypeError("model has wrong type %s, expected %s" + % (type(model), type(self._model))) + + # the following isn't strictly necessary, but it currently works + # around a bug with reused ids in the attribute cache and also + # makes a lot of sense for most applications (don't want a huge + # eternal cache pointing to models that you're not using anyway) + clear_attr_cache() + + # unregister previous proxy + self._unregister_proxy_in_model() + + self._model = model + + for attribute, widget in self._model_attributes.items(): + self._reset_widget(attribute, widget) + + def add_widget(self, name, widget): + """ + Adds a new widget to the proxy + + @param name: name of the widget + @param widget: widget, must be a gtk.Widget subclass + """ + if name in self._model_attributes: + raise TypeError("there is already a widget called %s" % name) + + if not isinstance(widget, gtk.Widget): + raise TypeError("%r must be a gtk.Widget subclass" % widget) + + self._setup_widget(name, widget) + + def remove_widget(self, name): + """ + Removes a widget from the proxy + + @param name: the name of the widget to remove + """ + if not name in self._model_attributes: + raise TypeError("there is no widget called %s" % name) + + widget = self._model_attributes.pop(name) + if IValidatableProxyWidget.providedBy(widget): + connection_id = widget.get_data('content-changed-id') + widget.disconnect(connection_id) + + # Backwards compatibility + + def new_model(self, model, relax_type=False): + self.set_model(model) + new_model = deprecated('set_model', log)(new_model) + diff --git a/kiwi/ui/proxywidget.py b/kiwi/ui/proxywidget.py new file mode 100644 index 0000000..717e832 --- /dev/null +++ b/kiwi/ui/proxywidget.py @@ -0,0 +1,316 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> +# Daniel Saran R. da Cunha <daniel@async.com.br> +# + +"""Basic classes for widget support for the Kiwi Framework""" + +import gettext + +import gtk +from gtk import gdk + +from kiwi import ValueUnset +from kiwi.component import implements +from kiwi.datatypes import ValidationError, converter +from kiwi.environ import environ +from kiwi.interfaces import IProxyWidget, IValidatableProxyWidget +from kiwi.log import Logger +from kiwi.ui.gadgets import FadeOut +from kiwi.utils import gsignal, gproperty + +log = Logger('widget proxy') + +_ = gettext.gettext + +class ProxyWidgetMixin(object): + """This class is a mixin that provide a common interface for KiwiWidgets. + + Usually the Proxy class need to set and get data from the widgets. It also + need a validation framework. + + @cvar allowed_data_types: A list of types which we are allowed to use + in this class. + """ + + implements(IProxyWidget) + + gsignal('content-changed') + gsignal('validation-changed', bool) + gsignal('validate', object, retval=object) + + gproperty('data-type', object, blurb='Data Type') + gproperty('model-attribute', str, blurb='Model attribute') + + allowed_data_types = object, + + # To be able to call the as/from_string without setting the data_type + # property and still receiving a good warning. + _converter = None + + def __init__(self): + if not type(self.allowed_data_types) == tuple: + raise TypeError("%s.allowed_data_types must be a tuple" % ( + self.allowed_data_types)) + self._data_format = None + + # Properties + + def prop_set_data_type(self, data_type): + """Set the data type for the widget + + @param data_type: can be None, a type object or a string with the + name of the type object, so None, "<type 'str'>" + or 'str' + """ + if data_type is None: + return data_type + + # This may convert from string to type, + # A type object will always be returned + data_type = converter.check_supported(data_type) + + if not issubclass(data_type, self.allowed_data_types): + raise TypeError( + "%s only accept %s types, not %r" + % (self, + ' or '.join([t.__name__ for t in self.allowed_data_types]), + data_type)) + + self._converter = converter.get_converter(data_type) + return data_type + + # Public API + def set_data_format(self, format): + self._data_format = format + + def read(self): + """Get the content of the widget. + The type of the return value + @returns: None if the user input a invalid value + @rtype: Must matche the data-type property. + """ + raise NotImplementedError + + def update(self, value): + """ + @param value: + """ + raise NotImplementedError + + # Private + + def _as_string(self, data): + """Convert a value to a string + @param data: data to convert + """ + conv = self._converter + if conv is None: + raise AssertionError( + "You need to set a data type before calling _as_string") + + return conv.as_string(data, format=self._data_format) + + def _from_string(self, data): + """Convert a string to the data type of the widget + This may raise a L{kiwi.datatypes.ValidationError} if conversion + failed + @param data: data to convert + """ + conv = self._converter + if conv is None: + raise AssertionError( + "You need to set a data type before calling _from_string") + + return conv.from_string(data) + +MANDATORY_ICON = gtk.STOCK_EDIT +ERROR_ICON = gdk.pixbuf_new_from_file( + environ.find_resource('pixmap', 'validation-error-16.png')) + +class ValidatableProxyWidgetMixin(ProxyWidgetMixin): + """Class used by some Kiwi Widgets that need to support mandatory + input and validation features such as custom validation and data-type + validation. + + Mandatory support provides a way to warn the user when input is necessary. + The validatation feature provides a way to check the data entered and to + display information about what is wrong. + """ + + implements(IValidatableProxyWidget) + + gproperty('mandatory', bool, default=False) + + def __init__(self, widget=None): + ProxyWidgetMixin.__init__(self) + + self._valid = True + self._fade = FadeOut(self) + self._fade.connect('color-changed', self._on_fadeout__color_changed) + + # Override in subclass + + def update_background(self, color): + "Implement in subclass" + + def set_pixbuf(self, pixbuf): + "Implement in subclass" + + def get_icon_window(self): + "Implement in subclass" + + def set_tooltip(self, text): + "Implement in subclass" + + # Public API + + def is_valid(self): + """ + @returns: True if the widget is in validated state + """ + return self._valid + + def validate(self, force=False): + """Checks if the data is valid. + Validates data-type and custom validation. + + @param force: if True, force validation + @returns: validated data or ValueUnset if it failed + """ + + try: + data = self.read() + log.debug('Read %r for %s' % (data, self.model_attribute)) + + # check if we should draw the mandatory icon + # this need to be done before any data conversion because we + # we don't want to end drawing two icons + if self.mandatory and (data == None or + data == '' or + data == ValueUnset): + self.set_blank() + return ValueUnset + else: + + # The widgets themselves have now valid the data + # Next step is to call the application specificed + # checks, which are found in the view. + if data is not None and data is not ValueUnset: + # this signal calls the on_widgetname__validate method + # of the view class and gets the exception (if any). + error = self.emit("validate", data) + if error: + raise error + + self.set_valid() + return data + except ValidationError, e: + self.set_invalid(str(e)) + return ValueUnset + + def set_valid(self): + """Changes the validation state to valid, which will remove icons and + reset the background color + """ + + log.debug('Setting state for %s to VALID' % self.model_attribute) + self._set_valid_state(True) + + self._fade.stop() + self.set_pixbuf(None) + + def set_invalid(self, text=None, fade=True): + """Changes the validation state to invalid. + @param text: text of tooltip of None + @param fade: if we should fade the background + """ + log.debug('Setting state for %s to INVALID' % self.model_attribute) + + self._set_valid_state(False) + + if not fade: + return + + # If there is no error text, set a generic one so the error icon + # still have a tooltip + if not text: + text = _("'%s' is not a valid value for this field") % self.read() + + self.set_tooltip(text) + + # When the fading animation is finished, set the error icon + # We don't need to check if the state is valid, since stop() + # (which removes this timeout) is called as soon as the user + # types valid data. + def done(fadeout, c): + self.set_pixbuf(ERROR_ICON) + self.queue_draw() + fadeout.disconnect(c.signal_id) + + class SignalContainer: + pass + c = SignalContainer() + c.signal_id = self._fade.connect('done', done, c) + + if self._fade.start(): + self.set_pixbuf(None) + + def set_blank(self): + """Changes the validation state to blank state, this only applies + for mandatory widgets, draw an icon and set a tooltip""" + + log.debug('Setting state for %s to BLANK' % self.model_attribute) + + if self.mandatory: + self._draw_stock_icon(MANDATORY_ICON) + self.set_tooltip(_('This field is mandatory')) + self._fade.stop() + valid = False + else: + valid = True + + self._set_valid_state(valid) + + # Private + + def _set_valid_state(self, state): + """Updates the validation state and emits a signal iff it changed""" + + if self._valid == state: + return + + self.emit('validation-changed', state) + self._valid = state + + def _draw_stock_icon(self, stock_id): + icon = self.render_icon(stock_id, gtk.ICON_SIZE_MENU) + self.set_pixbuf(icon) + self.queue_draw() + + # Callbacks + + def _on_fadeout__color_changed(self, fadeout, color): + self.update_background(color) diff --git a/kiwi/ui/selectablebox.py b/kiwi/ui/selectablebox.py new file mode 100644 index 0000000..33d2085 --- /dev/null +++ b/kiwi/ui/selectablebox.py @@ -0,0 +1,186 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +""" +A box which you can select and will have a border around it when +you click on any widgets in it +""" + +import gtk +from gtk import gdk + +class SelectableBox(object): + def __init__(self, width=4): + self._selected = None + self._draw_gc = None + self._selection_width = width + self.unset_flags(gtk.NO_WINDOW) + self.set_redraw_on_allocate(True) + self.set_spacing(width) + self.set_border_width(width) + + # Public API + + def get_selected(self): + """ + @returns: the currently selected widget + """ + + return self._selected + + def set_selected(self, widget): + """ + @param widget: widget to select, must be a children of self + """ + + if not widget in self.get_children(): + raise ValueError("widget must be a child of %r" % self) + + old_selected = self._selected + self._selected = widget + if old_selected != widget: + self.queue_draw() + + def pack_start(self, child, expand=True, fill=True, padding=0): + """ + Identical to gtk.Box.pack_start + """ + super(SelectableBox, self).pack_start(child, expand=expand, + fill=fill, padding=padding) + self._child_added(child) + + def pack_end(self, child, expand=True, fill=True, padding=0): + """ + Identical to gtk.Box.pack_end + """ + super(SelectableBox, self).pack_end(child, expand=expand, + fill=fill, padding=padding) + self._child_added(child) + + def add(self, child): + """ + Identical to gtk.Container.add + """ + super(SelectableBox, self).add(child) + self._child_added(child) + + def update_selection(self): + selected = self._selected + if not selected: + return + + border = self._selection_width + x, y, w, h = selected.allocation + self.window.draw_rectangle(self._draw_gc, False, + x - (border / 2), y - (border / 2), + w + border, h + border) + + # GtkWidget + + def do_realize(self): + assert not (self.flags() & gtk.NO_WINDOW) + self.set_flags(self.flags() | gtk.REALIZED) + self.window = gdk.Window(self.get_parent_window(), + width=self.allocation.width, + height=self.allocation.height, + window_type=gdk.WINDOW_CHILD, + wclass=gdk.INPUT_OUTPUT, + event_mask=(self.get_events() | + gdk.EXPOSURE_MASK | + gdk.BUTTON_PRESS_MASK)) + self.window.set_user_data(self) + self.style.attach(self.window) + self.style.set_background(self.window, gtk.STATE_NORMAL) + + self._draw_gc = gdk.GC(self.window, + line_width=self._selection_width, + line_style=gdk.SOLID, + foreground=self.style.bg[gtk.STATE_SELECTED]) + + def do_button_press_event(self, event): + selected = self._get_child_at_pos(int(event.x), int(event.y)) + if selected: + self.set_selected(selected) + + # Private + + def _get_child_at_pos(self, x, y): + """ + @param x: x coordinate + @type x: integer + @param y: y coordinate + @type y: integer + """ + toplevel = self.get_toplevel() + for child in self.get_children(): + coords = toplevel.translate_coordinates(child, x, y) + if not coords: + continue + + child_x, child_y = coords + if (0 <= child_x < child.allocation.width and + 0 <= child_y < child.allocation.height and + child.flags() & (gtk.MAPPED | gtk.VISIBLE)): + return child + + def _child_added(self, child): + child.connect('button-press-event', + lambda child, e: self.set_selected(child)) + +class SelectableHBox(SelectableBox, gtk.HBox): + __gtype_name__ = 'SelectableHBox' + + def __init__(self, width=4): + gtk.HBox.__init__(self) + SelectableBox.__init__(self, width=width) + + do_realize = SelectableBox.do_realize + do_button_press_event = SelectableBox.do_button_press_event + + def do_size_allocate(self, allocation): + gtk.HBox.do_size_allocate(self, allocation) + if self.flags() & gtk.REALIZED: + self.window.move_resize(*allocation) + + def do_expose_event(self, event): + gtk.HBox.do_expose_event(self, event) + self.update_selection() + +class SelectableVBox(SelectableBox, gtk.VBox): + __gtype_name__ = 'SelectableVBox' + + def __init__(self, width=4): + gtk.VBox.__init__(self) + SelectableBox.__init__(self, width=width) + + do_realize = SelectableBox.do_realize + do_button_press_event = SelectableBox.do_button_press_event + + def do_size_allocate(self, allocation): + gtk.VBox.do_size_allocate(self, allocation) + if self.flags() & gtk.REALIZED: + self.window.move_resize(*allocation) + + def do_expose_event(self, event): + gtk.VBox.do_expose_event(self, event) + self.update_selection() diff --git a/kiwi/ui/test/__init__.py b/kiwi/ui/test/__init__.py new file mode 100644 index 0000000..436680f --- /dev/null +++ b/kiwi/ui/test/__init__.py @@ -0,0 +1,24 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br +# + +"""User interface: Testing""" diff --git a/kiwi/ui/test/common.py b/kiwi/ui/test/common.py new file mode 100644 index 0000000..37474e7 --- /dev/null +++ b/kiwi/ui/test/common.py @@ -0,0 +1,198 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br +# + +""" +Common routines used by L{kiwi.ui.test.listener.Listener} and +L{kiwi.ui.test.player.Player} +""" + +import sets + +import gobject +import gtk + +class Base(object): + def __init__(self): + self._windows = {} + self._window_list = self._list_windows() + gobject.timeout_add(25, self._check_windows) + self._objects = {} + + # Public API + + def parse_one(self, toplevel, gobj): + """ + @param toplevel: + @param gobj: + """ + + if not isinstance(gobj, gobject.GObject): + raise TypeError + + gtype = gobj + while True: + name = gobject.type_name(gtype) + func = getattr(self, name, None) + if func: + if func(toplevel, gobj): + break + if gtype == gobject.GObject.__gtype__: + break + + gtype = gobject.type_parent(gtype) + + def get_object(self, attr): + """ + @param attr: name of toplevel object to get + @returns: toplevel object + """ + return self._objects[attr] + + # Override in subclass + + def window_added(self, window): + """ + This will be called when a window is displayed + @param window: + """ + + def window_removed(self, window): + """ + This will be called when a window is destroyed + @param window: + """ + + # Private + + def _on_window_name_change(self, window, pspec, old_name): + # Update datastructures, no need to notify that the dialog + # was added, we already know about it and all its children + self._windows[window.get_name()] = self._windows.pop(old_name) + + def _list_windows(self): + # We're only interested in toplevels for now, tooltip windows are + # popups for example + rv = [] + for window in gtk.window_list_toplevels(): + if window.type != gtk.WINDOW_TOPLEVEL: + if not isinstance(window.child, gtk.Menu): + continue + + # Hack to get all the entries of a popup menu in + # the same namespace as the window they were launched + # in. + parent_menu = window.child.get_data('parent-menu') + if parent_menu: + main = parent_menu.get_toplevel() + rv.append((main.get_name(), window)) + else: + rv.append((window.get_name(), window)) + + return sets.Set(rv) + + def _check_windows(self): + new_windows = self._list_windows() + if self._windows != new_windows: + for name, window in new_windows.difference(self._window_list): + # Popup window, eg menu popups needs to be treated + # specially, only parse the contained widgets, do not + # add it or listen to name changes, we don't care about them + if window.type == gtk.WINDOW_POPUP: + # XXX: This is trigged by stoq.test.gui.salewizard + if name in self._windows: + toplevel = self._windows[name] + self.parse_one(toplevel, window) + else: + self.parse_one(window, window) + + window.connect('notify::name', self._on_window_name_change, + window.get_name()) + self.window_added(window) + self._windows[name] = window + + for name, window in self._window_list.difference(new_windows): + # We don care about popup windows, see above + if window.type == gtk.WINDOW_POPUP: + continue + + self.window_removed(window) + del self._windows[name] + + self._window_list = new_windows + return True + + def ignore(self, toplevel, gobj): + pass + + GtkSeparatorMenuItem = GtkTearoffMenuItem = ignore + + def _add_widget(self, toplevel, widget, name): + toplevel_widgets = self._objects.setdefault(toplevel.get_name(), {}) + if not name in toplevel_widgets: + toplevel_widgets[name] = widget + + def GtkWidget(self, toplevel, widget): + """ + Called when a GtkWidget is about to be traversed + """ + self._add_widget(toplevel, widget, widget.get_name()) + + def GtkContainer(self, toplevel, container): + """ + Called when a GtkContainer is about to be traversed + + Parsers all the children and listens for new children, which + may be added at a later point. + """ + for child in container.get_children(): + self.parse_one(toplevel, child) + + def _on_container_add(container, widget): + self.parse_one(toplevel, widget) + container.connect('add', _on_container_add) + + def GtkDialog(self, toplevel, dialog): + """ + Called when a GtkDialog is about to be traversed + + Just parses the widgets embedded in the dialogs. + """ + self.parse_one(toplevel, dialog.action_area) + self.parse_one(toplevel, dialog.vbox) + + def GtkMenuItem(self, toplevel, item): + """ + Called when a GtkMenuItem is about to be traversed + + It does some magic to tie a stronger connection between toplevel + menuitems and submenus, which later will be used. + """ + submenu = item.get_submenu() + if submenu: + submenu.set_data('parent-menu', item) + for child_item in submenu.get_children(): + child_item.set_data('parent-menu', item) + self.parse_one(toplevel, submenu) + + def GtkToolButton(self, toplevel, item): + item.child.set_name(item.get_name()) diff --git a/kiwi/ui/test/listener.py b/kiwi/ui/test/listener.py new file mode 100644 index 0000000..bfb4583 --- /dev/null +++ b/kiwi/ui/test/listener.py @@ -0,0 +1,458 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br +# + +""" +User interface event listener and serializer. + +This module provides an interface for creating, listening to +and saving events. +It uses the gobject introspection base class +L{kiwi.ui.test.common.Base} to gather widgets, windows and other objects. + +The user interfaces are saved in a format so they can easily be played +back by simply executing the script through a standard python interpreter. +""" + +import atexit + +from gtk import gdk +import gtk + +from kiwi.ui.test.common import Base +from kiwi.ui.combomixin import ComboMixin +from kiwi.ui.objectlist import ObjectList + +_events = [] + +def register_event_type(event_type): + """ + Add an event type to a list of event types. + + @param event_type: a L{Event} subclass + """ + if event_type in _events: + raise AssertionError("event %s already registered" % event_type) + _events.append(event_type) + +def get_event_types(): + """ + Returns the collection of event types. + @returns: the event types. + """ + return _events + +class SkipEvent(Exception): + pass + +class Event(object): + """ + Event is a base class for all events. + An event represent a user change of an interactive widget. + @cvar object_type: subclass for type, L{Listener} uses this to + automatically attach events to objects when they appear + """ + object_type = None + def __init__(self, object, name=None): + """ + @param object: a gobject subclass + @param name: name of the object, if None, the + method get_name() will be called + """ + self.object = object + if name is None: + name = object.get_name() + self.name = name + self.toplevel_name = self.get_toplevel(object).get_name() + + # Override in subclass + def get_toplevel(self, widget): + """ + This fetches the toplevel widget for a specific object, + by default it assumes it's a wiget subclass and calls + get_toplevel() for the widget + + Override this in a subclass. + """ + return widget.get_toplevel() + + def serialize(self): + """ + Serialize the widget, write the code here which is + used to reproduce the event, for a button which is clicked + the implementation looks like this: + + >>> def serialize(self): + >>> ... return '%s.clicked' % self.name + + @returns: string to reproduce event + Override this in a subclass. + """ + pass + +class SignalEvent(Event): + """ + A SignalEvent is an L{Event} which is tied to a GObject signal, + L{Listener} uses this to automatically attach itself to a signal + at which point this object will be instantiated. + + @cvar signal_name: signal to listen to + """ + signal_name = None + def __init__(self, object, name, args): + """ + @param object: + @param name: + @param args: + """ + Event.__init__(self, object, name) + self.args = args + + def connect(cls, object, signal_name, cb): + """ + Calls connect on I{object} for signal I{signal_name}. + + @param object: object to connect on + @param signal_name: signal name to listen to + @param cb: callback + """ + object.connect(signal_name, cb, cls, object) + connect = classmethod(connect) + +class WindowDeleteEvent(SignalEvent): + """ + This event represents a user click on the close button in the + window manager. + """ + + signal_name = 'delete-event' + object_type = gtk.Window + + def serialize(self): + return 'delete_window("%s")' % self.name + +register_event_type(WindowDeleteEvent) + +class MenuItemActivateEvent(SignalEvent): + """ + This event represents a user click on a menu item. + It could be a toplevel or a normal entry in a submenu. + """ + signal_name = 'activate' + object_type = gtk.MenuItem + + def serialize(self): + return '%s.activate()' % self.name +register_event_type(MenuItemActivateEvent) + +class ImageMenuItemButtonReleaseEvent(SignalEvent): + """ + This event represents a click on a normal menu entry + It's sort of a hack to use button-press-event, instea + of listening to activate, but we'll get the active callback + after the user specified callbacks are called, at which point + it is already too late. + """ + signal_name = 'button-release-event' + object_type = gtk.ImageMenuItem + + def get_toplevel(self, widget): + parent = widget + while True: + widget = parent.get_data('parent-menu') + if not widget: + break + parent = widget + toplevel = parent.get_toplevel() + return toplevel + + def serialize(self): + return '%s.activate()' % self.name +register_event_type(ImageMenuItemButtonReleaseEvent) + +class ToolButtonReleaseEvent(SignalEvent): + """ + This event represents a click on a normal toolbar button + Hackish, see L{ImageMenuItemButtonReleaseEvent} for more details. + """ + signal_name = 'button-release-event' + object_type = gtk.Button + + def serialize(self): + return '%s.activate()' % self.name +register_event_type(ToolButtonReleaseEvent) + +class EntrySetTextEvent(SignalEvent): + """ + This event represents a content modification of a GtkEntry. + When the user deletes, clears, adds, modifies the text this + event will be created. + """ + signal_name = 'notify::text' + object_type = gtk.Entry + + def __init__(self, object, name, args): + SignalEvent.__init__(self, object, name, args) + self.text = self.object.get_text() + + def serialize(self): + return '%s.set_text("%s")' % (self.name, self.text) +register_event_type(EntrySetTextEvent) + +class EntryActivateEvent(SignalEvent): + """ + This event represents an activate event for a GtkEntry, eg when + the user presses enter in a GtkEntry. + """ + + signal_name = 'activate' + object_type = gtk.Entry + + def serialize(self): + return '%s.activate()' % (self.name) +register_event_type(EntryActivateEvent) + +# Also works for Toggle, Radio and Check +class ButtonClickedEvent(SignalEvent): + """ + This event represents a button click. + Note that this will also work for GtkToggleButton, GtkRadioButton + and GtkCheckButton. + """ + signal_name = 'clicked' + object_type = gtk.Button + + def serialize(self): + return '%s.clicked()' % self.name +register_event_type(ButtonClickedEvent) + +# Kiwi widget support +class ObjectListSelectionChanged(SignalEvent): + """ + This event represents a selection change on a + L{kiwi.ui.objectlist.ObjectList}, + eg when the user selects or unselects a row. + It is actually tied to the signal changed on GtkTreeSelection object. + """ + object_type = ObjectList + signal_name = 'changed' + def __init__(self, objectlist, name, args): + self._objectlist = objectlist + SignalEvent.__init__(self, objectlist, name=objectlist.get_name(), + args=args) + self.rows = self._get_rows() + + def _get_rows(self): + selection = self._objectlist.get_treeview().get_selection() + + if selection.get_mode() == gtk.SELECTION_MULTIPLE: + # get_selected_rows() returns a list of paths + iters = selection.get_selected_rows()[1] + if iters: + return iters + else: + # while get_selected returns an iter, yay. + model, iter = selection.get_selected() + if iter is not None: + # so convert it to a path and put it in an empty list + return [model.get_string_from_iter(iter)] + + return [] + + def connect(cls, orig, signal_name, cb): + object = orig.get_treeview().get_selection() + object.connect(signal_name, cb, cls, orig) + connect = classmethod(connect) + + def get_toplevel(self, widget): + return self._objectlist.get_toplevel() + + def serialize(self): + return '%s.select_paths(%s)' % (self.name, self.rows) +register_event_type(ObjectListSelectionChanged) + +class ObjectListDoubleClick(SignalEvent): + """ + This event represents a double click on a row in objectlist + """ + signal_name = 'button-press-event' + object_type = ObjectList + + def __init__(self, objectlist, name, args): + event, = args + if event.type != gdk._2BUTTON_PRESS: + raise SkipEvent + + SignalEvent.__init__(self, objectlist, name, args) + self.row = objectlist.get_selected_row_number() + + def connect(cls, orig, signal_name, cb): + object = orig.get_treeview() + object.connect(signal_name, cb, cls, orig) + connect = classmethod(connect) + + def serialize(self): + return '%s.double_click(%s)' % (self.name, self.row) +register_event_type(ObjectListDoubleClick) + +class KiwiComboBoxChangedEvent(SignalEvent): + """ + This event represents a a selection of an item + in a L{kiwi.ui.widgets.combobox.ComboBoxEntry} or + L{kiwi.ui.widgets.combobox.ComboBox}. + """ + signal_name = 'changed' + object_type = ComboMixin + def __init__(self, combo, name, args): + SignalEvent.__init__(self, combo, name, args) + self.label = combo.get_selected_label() + + def serialize(self): + return '%s.select_item_by_label("%s")' % (self.name, self.label) + +register_event_type(KiwiComboBoxChangedEvent) + +class Listener(Base): + """ + Listener takes care of attaching events to widgets, when the appear, + and creates the events when the user is interacting with some widgets. + When the tracked program is closed the events are serialized into + a script which can be played back with help of + L{kiwi.ui.test.player.Player}. + """ + + def __init__(self, filename, args): + """ + @param filename: name of the script + @param args: command line used to run the script + """ + Base.__init__(self) + self._filename = filename + self._args = args + self._events = [] + self._listened_objects = [] + self._event_types = self._configure_event_types() + + atexit.register(self.save) + + def _configure_event_types(self): + event_types = {} + for event_type in get_event_types(): + if event_type.object_type is None: + raise AssertionError + elist = event_types.setdefault(event_type.object_type, []) + elist.append(event_type) + + return event_types + + def _add_event(self, event): + self._events.append(event) + + def _listen_event(self, object, event_type): + if not issubclass(event_type, SignalEvent): + raise TypeError("Can only listen to SignalEvents, not %r" + % event_type) + + if event_type.signal_name is None: + raise ValueError("signal_name cannot be None") + + # This is horrible, but there's no good way of passing in + # more than one variable to the script and we really want to be + # able to connect it to any kind of signal, regardless of + # the number of parameters the signal has + def on_signal(object, *args): + event_type, orig = args[-2:] + try: + self._add_event(event_type(orig, None, args[:-2])) + except SkipEvent: + pass + event_type.connect(object, event_type.signal_name, on_signal) + + def window_removed(self, window): + self._add_event(WindowDeleteEvent(window, None, [])) + + def parse_one(self, toplevel, gobj): + Base.parse_one(self, toplevel, gobj) + + # mark the object as "listened" to ensure we'll always + # receive unique objects + if gobj in self._listened_objects: + return + self._listened_objects.append(gobj) + + for object_type, event_types in self._event_types.items(): + if not isinstance(gobj, object_type): + continue + + for event_type in event_types: + # These 3 hacks should move into the event class itself + if event_type == MenuItemActivateEvent: + if not isinstance(gobj.get_parent(), gtk.MenuBar): + continue + elif event_type == ToolButtonReleaseEvent: + if not isinstance(gobj.get_parent(), gtk.ToolButton): + continue + elif event_type == ButtonClickedEvent: + if isinstance(gobj.get_parent(), gtk.ToolButton): + continue + + if issubclass(event_type, SignalEvent): + self._listen_event(gobj, event_type) + + def save(self): + """ + Collect events and serialize them into a script and save + the script. + This should be called when the tracked program has + finished executing. + """ + + try: + fd = open(self._filename, 'w') + except IOError: + raise SystemExit("Could not write: %s" % self._filename) + fd.write("from kiwi.ui.test.player import Player\n" + "\n" + "player = Player(%s)\n" + "app = player.get_app()\n" % repr(self._args)) + + windows = {} + + for event in self._events: + toplevel = event.toplevel_name + if not toplevel in windows: + fd.write('\n' + 'player.wait_for_window("%s")\n' % toplevel) + windows[toplevel] = True + + if isinstance(event, WindowDeleteEvent): + fd.write("player.%s\n\n" % (event.serialize())) + if not event.name in windows: + # Actually a bug + continue + del windows[event.name] + else: + fd.write("app.%s.%s\n" % (toplevel, + event.serialize())) + + fd.write('player.finish()\n') + fd.close() diff --git a/kiwi/ui/test/main.py b/kiwi/ui/test/main.py new file mode 100644 index 0000000..b1c1d37 --- /dev/null +++ b/kiwi/ui/test/main.py @@ -0,0 +1,52 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005,2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# + +""" +Kiwi UI Test: command line interface +""" + +import optparse +import sys + +def _play(filename, args): + from kiwi.ui.test.player import play_file + play_file(filename, args) + +def _record(filename, args): + from kiwi.ui.test.listener import Listener + + Listener(filename, args[1:]) + + sys.argv = args[1:] + execfile(sys.argv[0]) + +def main(args): + parser = optparse.OptionParser() + parser.add_option('', '--record', action="store", + dest="record") + options, args = parser.parse_args(args) + + if options.record: + _record(options.record, args) + else: + if len(args) < 2: + raise SystemExit("Error: needs a filename to play") + _play(args[1], args[2:]) diff --git a/kiwi/ui/test/player.py b/kiwi/ui/test/player.py new file mode 100644 index 0000000..0e17e16 --- /dev/null +++ b/kiwi/ui/test/player.py @@ -0,0 +1,250 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005,2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br +# + +""" +Test script playback system and infrastructure. +""" +import os +import sys +import threading +import time + +import gobject +gobject.threads_init() +import gtk +from gtk import gdk +gdk.threads_init() + +from kiwi.log import Logger +from kiwi.ui.test.common import Base + +WINDOW_TIMEOUT = 10 +WINDOW_WAIT = 0.5 +WIDGET_TIMEOUT = 2 + +# This is pretty important, it gives the application 2 seconds +# to finish closing the dialog, eg write stuff to the database and +# yada yada +DELETE_WINDOW_WAIT = 4 + +class TimeOutError(Exception): + """ + Exception that will be raised when a widget cannot be found, + which will happen after a few seconds depending on the type + of widget + """ + pass + +log = Logger('uitest') + +class ThreadSafeFunction: + """ + A function which is safe thread in the mainloop context + All widgets and object functions will be wrapped by this. + """ + + def __init__(self, func, obj_name): + self._func = func + self._obj_name = obj_name + + def _invoke(self, *args, **kwargs): + gdk.threads_enter() + log('Calling %s.%s(%s)' % (self._obj_name, + self._func.__name__, + ', '.join(map(repr, args)))) + self._func(*args, **kwargs) + gdk.threads_leave() + return False + + def __call__(self, *args, **kwargs): + # dialog.run locks us out + #rv = self._func(*args, **kwargs) + gobject.idle_add(self._invoke, *args, **kwargs) + +class ThreadSafeObject: + """ + A wrapper around a gobject which replaces all callable + objects which wraps all callable objects uses L{ThreadSafeFunction}. + """ + def __init__(self, gobj): + """ + @param gobj: + """ + self._gobj = gobj + + def __getattr__(self, name): + attr = getattr(self._gobj, name, None) + if attr is None: + raise KeyError(name) + if callable(attr): + return ThreadSafeFunction(attr, self._gobj.get_name()) + return attr + +class DictWrapper(object): + def __init__(self, dict, name): + self._dict = dict + self._name = name + + def __getattr__(self, attr): + start = time.time() + while True: + if (time.time() - start) > WIDGET_TIMEOUT: + raise TimeOutError("no %s called %s" % (self._name, attr)) + + if attr in self._dict: + return ThreadSafeObject(self._dict[attr]) + + time.sleep(0.1) + +class App(DictWrapper): + def __init__(self, player): + self._player = player + + def __getattr__(self, attr): + return DictWrapper(self._player.get_object(attr), 'widget') + +class Player(Base): + """ + Event playback object. Usually used inside a scripted generated by + L{kiwi.ui.test.listener.Listener}. + + The application script will be exectured in a different thread, + so to be able to conveniently use it a number of tricks are used + to avoid making the user worry about threadsafety. + """ + def __init__(self, args): + """ + @param args: + """ + Base.__init__(self) + + self._app = App(self) + + if not os.path.exists(args[0]): + print >> sys.stderr, \ + "ERROR: %s: No such a file or directory" % args[0] + os._exit(1) + + # Send notification to main thread + gobject.idle_add(self._start_app, args) + + def _start_app(self, args): + sys.argv = args[:] + execfile(sys.argv[0]) + + # Run all pending events, such as idle adds + while gtk.events_pending(): + gtk.main_iteration() + + def get_app(self): + """ + Returns a virtual application object, which is a special object + where you can access the windows as attributes and widget in the + windows as attributes on the window, examples: + + >>> app = player.get_app() + >>> app.WindowName.WidgetName.method() + + @return: virtual application object + """ + return self._app + + def wait_for_window(self, name, timeout=WINDOW_TIMEOUT): + """ + Waits for a window with name I{name} to appear. + + @param name: the name of the window to wait for + @param timeout: number of seconds to wait after the window appeared. + """ + + log('waiting for %s (%d)' % (name, timeout)) + + # XXX: No polling! + start_time = time.time() + while True: + if name in self._objects: + window = self.get_object(name) + time.sleep(WINDOW_WAIT) + return window + + if time.time() - start_time > timeout: + raise TimeOutError("could not find window %s" % name) + time.sleep(0.05) + + def delete_window(self, window_name): + """ + Deletes a window, creates a delete-event and sends it to the window + """ + + log('deleting window %s' % window_name) + + if window_name in self._objects: + del self._objects[window_name] + + start_time = time.time() + while True: + window = self._windows.get(window_name) + # If the window is already removed, skip + if (not window_name in self._windows or + window is None or + window.window is None): + return False + + if time.time() - start_time > DELETE_WINDOW_WAIT: + event = gdk.Event(gdk.DELETE) + event.window = window.window + event.put() + return True + time.sleep(0.1) + + def finish(self): + pass + +def play_file(filename, args=None): + """ + Plays a recorded script file + + @param filename: name to play + @param args: additional arguments to put in sys.argv + """ + if not os.path.exists(filename): + raise SystemExit("%s: No such a file or directory" % filename) + + if not args: + args = [] + + sys.argv = [filename] + args + + def _thread(filename): + try: + execfile(filename) + except: + import traceback + etype, value, tb = sys.exc_info() + traceback.print_exception(etype, value, tb.tb_next) + os._exit(1) + + t = threading.Thread(target=_thread, args=[filename]) + t.start() + + gobject.MainLoop().run() diff --git a/kiwi/ui/tooltip.py b/kiwi/ui/tooltip.py new file mode 100644 index 0000000..23ad395 --- /dev/null +++ b/kiwi/ui/tooltip.py @@ -0,0 +1,118 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +"""A tooltip popup window which only pop ups on demand, which +makes it possible for us to tie it to a specific gtk.gdk.Window +""" + +import gobject +import gtk + +DEFAULT_DELAY = 500 +BORDER_WIDTH = 4 + +class Tooltip(gtk.Window): + def __init__(self, widget): + gtk.Window.__init__(self, gtk.WINDOW_POPUP) + # from gtktooltips.c:gtk_tooltips_force_window + self.set_app_paintable(True) + self.set_resizable(False) + self.set_name("gtk-tooltips") + self.set_border_width(BORDER_WIDTH) + self.connect('expose-event', self._on__expose_event) + + self._label = gtk.Label() + self.add(self._label) + self._show_timeout_id = -1 + + # from gtktooltips.c:gtk_tooltips_draw_tips + def _calculate_pos(self, widget): + screen = widget.get_screen() + + w, h = self.size_request() + + x, y = widget.window.get_origin() + + if widget.flags() & gtk.NO_WINDOW: + x += widget.allocation.x + y += widget.allocation.y + + x = screen.get_root_window().get_pointer()[0] + x -= (w / 2 + BORDER_WIDTH) + + pointer_screen, px, py, _ = screen.get_display().get_pointer() + if pointer_screen != screen: + px = x + py = y + + monitor_num = screen.get_monitor_at_point(px, py) + monitor = screen.get_monitor_geometry(monitor_num) + + if (x + w) > monitor.x + monitor.width: + x -= (x + w) - (monitor.x + monitor.width); + elif x < monitor.x: + x = monitor.x + + if ((y + h + widget.allocation.height + BORDER_WIDTH) > + monitor.y + monitor.height): + y = y - h - BORDER_WIDTH + else: + y = y + widget.allocation.height + BORDER_WIDTH + + return x, y + + # from gtktooltips.c:gtk_tooltips_paint_window + def _on__expose_event(self, window, event): + w, h = window.size_request() + window.style.paint_flat_box(window.window, + gtk.STATE_NORMAL, gtk.SHADOW_OUT, + None, window, "tooltip", + 0, 0, w, h) + return False + + def _real_display(self, widget): + x, y = self._calculate_pos(widget) + + self.move(x, y) + self.show_all() + + # Public API + + def set_text(self, text): + self._label.set_text(text) + + def hide(self): + gtk.Window.hide(self) + gobject.source_remove(self._show_timeout_id) + self._show_timeout_id = -1 + + def display(self, widget): + if not self._label.get_text(): + return + + if self._show_timeout_id != -1: + return + + self._show_timeout_id = gobject.timeout_add(DEFAULT_DELAY, + self._real_display, + widget) diff --git a/kiwi/ui/views.py b/kiwi/ui/views.py new file mode 100644 index 0000000..0c68d4e --- /dev/null +++ b/kiwi/ui/views.py @@ -0,0 +1,967 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2001-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Jon Nelson <jnelson@securepipe.com> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Johan Dahlin <jdahlin@async.com.br> +# Henrique Romano <henrique@async.com.br> +# + +""" +Defines the View classes that are included in the Kiwi Framework, which +are the base of Delegates and Proxies. +""" + +import re +import string + +import gobject +import gtk +from gtk import gdk + +from kiwi.environ import is_gazpacho_required +from kiwi.interfaces import IValidatableProxyWidget +from kiwi.log import Logger +from kiwi.utils import gsignal, type_register +from kiwi.ui.gadgets import quit_if_last +from kiwi.ui.proxy import Proxy + +log = Logger('view') + +_non_interactive = ( + gtk.Label, + gtk.Alignment, + gtk.AccelLabel, + gtk.Arrow, + gtk.EventBox, + gtk.Fixed, + gtk.Frame, + gtk.HBox, + gtk.HButtonBox, + gtk.HPaned, + gtk.HSeparator, + gtk.Layout, + gtk.Progress, + gtk.ProgressBar, + gtk.ScrolledWindow, + gtk.Table, + gtk.VBox, + gtk.VButtonBox, + gtk.VPaned, + gtk.VSeparator, + gtk.Window, +) + +color_red = gdk.color_parse('red') +color_black = gdk.color_parse('black') + +# +# Signal brokers +# + +method_regex = re.compile(r'^(on|after)_(\w+)__(\w+)$') + +class SignalBroker(object): + def __init__(self, view, controller): + methods = controller._get_all_methods() + self._do_connections(view, methods) + + def _do_connections(self, view, methods): + """This method allows subclasses to add more connection mechanism""" + self._autoconnect_by_method_name(view, methods) + + def _autoconnect_by_method_name(self, view, methods): + """ + Offers autoconnection of widget signals based on function names. + You simply need to define your controller method in the format:: + + def on_widget_name__signal_name(self, widget, *args): + + In other words, start the method by "on_", followed by the + widget name, followed by two underscores ("__"), followed by the + signal name. Note: If more than one double underscore sequences + are in the string, the last one is assumed to separate the + signal name. + """ + self._autoconnected = {} + + for fname in methods.keys(): + # `on_x__y' has 7 chars and is the smallest possible handler + # (though illegal, of course, since the signal name x is bogus) + if len(fname) < 7: + continue + match = method_regex.match(fname) + if match is None: + continue + after, w_name, signal = match.groups() + widget = getattr(view, w_name, None) + if widget is None: + raise AttributeError("couldn't find widget %s in %s" + % (w_name, view)) + if not isinstance(widget, gobject.GObject): + raise AttributeError("%s (%s) is not a widget or an action " + "and can't be connected to" + % (w_name, widget)) + # Must use getattr; using the class method ends up with it + # being called unbound and lacking, thus, "self". + try: + if after: + signal_id = widget.connect_after(signal, methods[fname]) + else: + signal_id = widget.connect(signal, methods[fname]) + except TypeError: + log.warn("Widget %s doesn't provide a signal %s" % ( + widget.__class__, signal)) + continue + self._autoconnected.setdefault(widget, []).append(( + signal, signal_id)) + + def handler_block(self, widget, signal_name): + signals = self._autoconnected + if not widget in signals: + return + + for signal, signal_id in signals[widget]: + if signal_name is None or signal == signal_name: + widget.handler_block(signal_id) + + def handler_unblock(self, widget, signal_name): + signals = self._autoconnected + if not widget in signals: + return + + for signal, signal_id in signals[widget]: + if signal_name is None or signal == signal_name: + widget.handler_unblock(signal_id) + + def disconnect_autoconnected(self): + for widget, signals in self._autoconnected.items(): + for signal in signals: + widget.disconnect(signal[1]) + +class GladeSignalBroker(SignalBroker): + def _do_connections(self, view, methods): + super(GladeSignalBroker, self)._do_connections(view, methods) + self._connect_glade_signals(view, methods) + + def _connect_glade_signals(self, view, methods): + # mainly because the two classes cannot have a common base + # class. studying the class layout carefully or using + # composition may be necessary. + + # called by framework.basecontroller. takes a controller, and + # creates the dictionary to attach to the signals in the tree. + if not methods: + raise AssertionError("controller must be provided") + + dict = {} + for name, method in methods.items(): + if callable(method): + dict[name] = method + view._glade_adaptor.signal_autoconnect(dict) + + +class SlaveView(gobject.GObject): + """ + Base class for all View classes. Defines the essential class + attributes (controller, toplevel, widgets) and handles + initialization of toplevel and widgets. Once + AbstractView.__init__() has been called, you can be sure + self.toplevel and self.widgets are sane and processed. + + When a controller is associated with a View (the view should be + passed in to its constructor) it will try and call a hook in the + View called _attach_callbacks. See AbstractGladeView for an example + of this method. + """ + controller = None + toplevel = None + widgets = [] + toplevel_name = None + gladefile = None + gladename = None + domain = None + + # This signal is emited when the view wants to return a result value + gsignal("result", object) + + # This is emitted when validation changed for a view + # Used by parents views to know when child slaves changes + gsignal('validation-changed', bool) + + def __init__(self, toplevel=None, widgets=None, gladefile=None, + gladename=None, toplevel_name=None, domain=None): + """ Creates a new SlaveView. Sets up self.toplevel and self.widgets + and checks for reserved names. + """ + gobject.GObject.__init__(self) + + self._broker = None + self.slaves = {} + self._proxies = [] + self._valid = True + + # slave/widget name -> validation status + self._validation = {} + + # stores the function that will be called when widgets + # validity is checked + self._validate_function = None + + # setup the initial state with the value of the arguments or the + # class variables + klass = type(self) + self.toplevel = toplevel or klass.toplevel + self.widgets = widgets or klass.widgets + self.gladefile = gladefile or klass.gladefile + self.gladename = gladename or klass.gladename + self.toplevel_name = (toplevel_name or + klass.toplevel_name or + self.gladefile or + self.gladename) + self.domain = domain or klass.domain + + self._check_reserved() + self._glade_adaptor = self.get_glade_adaptor() + self.toplevel = self._get_toplevel() + + # grab the accel groups + self._accel_groups = gtk.accel_groups_from_object(self.toplevel) + + # XXX: support normal widgets + # notebook page label widget -> + # dict (slave name -> validation status) + self._notebook_validation = {} + self._notebooks = self._get_notebooks() + + def _get_notebooks(self): + if not self._glade_adaptor: + return [] + + return [widget for widget in self._glade_adaptor.get_widgets() + if isinstance(widget, gtk.Notebook)] + + def _check_reserved(self): + for reserved in ["widgets", "toplevel", "gladefile", + "gladename", "tree", "model", "controller"]: + # XXX: take into account widget constructor? + if reserved in self.widgets: + raise AttributeError( + "The widgets list for %s contains a widget named `%s', " + "which is a reserved. name""" % (self, reserved)) + + def _get_toplevel(self): + toplevel = self.toplevel + if not toplevel and self.toplevel_name: + if self._glade_adaptor: + toplevel = self._glade_adaptor.get_widget(self.toplevel_name) + else: + toplevel = getattr(self, self.toplevel_name, None) + + if not toplevel: + raise TypeError("A View requires an instance variable " + "called toplevel that specifies the " + "toplevel widget in it") + + if isinstance(toplevel, gtk.Window): + if toplevel.flags() & gtk.VISIBLE: + log.warn("Toplevel widget %s (%s) is visible; that's probably " + "wrong" % (toplevel, toplevel.get_name())) + + return toplevel + + def get_glade_adaptor(self): + """Special init code that subclasses may want to override.""" + if not self.gladefile: + return + + glade_adaptor = _open_glade(self, self.gladefile, + self.widgets, self.gladename, + self.domain) + + container_name = self.toplevel_name + if not container_name: + raise ValueError( + "You provided a gladefile %s to grab the widgets from " + "but you didn't give me a toplevel/container name!" % + self.gladefile) + + # a SlaveView inside a glade file needs to come inside a toplevel + # window, so we pull our slave out from it, grab its groups and + # muerder it later + shell = glade_adaptor.get_widget(container_name) + if not isinstance(shell, gtk.Window): + raise TypeError("Container %s should be a Window, found %s" % ( + container_name, type(shell))) + + self.toplevel = shell.get_child() + shell.remove(self.toplevel) + shell.destroy() + + return glade_adaptor + + # + # Hooks + # + + def on_attach(self, parent): + """ Hook function called when attach_slave is performed on slave views. + """ + pass + + def on_startup(self): + """ + This is a virtual method that can be customized by classes that + want to perform additional initalization after a controller has + been set for it. If you need this, add this method to your View + subclass and BaseController will call it when the controller is + set to the proxy.""" + pass + + # + # Accessors + # + + def get_toplevel(self): + """Returns the toplevel widget in the view""" + return self.toplevel + + def get_widget(self, name): + """Retrieves the named widget from the View""" + name = string.replace(name, '.', '_') + widget = getattr(self, name, None) + if widget is None: + raise AttributeError("Widget %s not found in view %s" + % (name, self)) + if not isinstance(widget, gtk.Widget): + raise TypeError("%s in view %s is not a Widget" + % (name, self)) + return widget + + def set_controller(self, controller): + """ + Sets the view's controller, checking to see if one has already + been set before.""" + # Only one controller per view, please + if self.controller: + raise AssertionError("This view already has a controller: %s" + % self.controller) + self.controller = controller + + # + # GTK+ proxies and convenience functions + # + + def show_and_loop(self, parent=None): + """ + Runs show() and runs the GTK+ event loop. If the parent + argument is supplied and is a valid view, this view is set as a + transient for the parent view + + @param parent: + """ + + self.show() + if parent: + self.set_transient_for(parent) + gtk.main() + + def show(self, *args): + """Shows the toplevel widget""" + self.toplevel.show() + + def show_all(self, *args): + """Shows all widgets attached to the toplevel widget""" + if self._glade_adaptor is not None: + raise AssertionError("You don't want to call show_all on a " + "SlaveView. Use show() instead.") + self.toplevel.show_all() + + def focus_toplevel(self): + """Focuses the toplevel widget in the view""" + # XXX: warn if there is no GdkWindow + if self.toplevel and self.toplevel.window is not None: + self.toplevel.grab_focus() + + def focus_topmost(self, widgets=None): + """ + Looks through widgets specified (if no widgets are specified, + look through all widgets attached to the view and sets focus to + the widget that is rendered in the position closest to the view + window's top and left + + - widgets: a list of widget names to be searched through + """ + widget = self.get_topmost_widget(widgets, can_focus=True) + if widget is not None: + widget.grab_focus() + # So it can be idle_added safely + return False + + def get_topmost_widget(self, widgets=None, can_focus=False): + """ + A real hack; returns the widget that is most to the left and + top of the window. + + - widgets: a list of widget names. If widgets is supplied, + it only checks in the widgets in the list; otherwise, it + looks at the widgets named in self.widgets, or, if + self.widgets is None, looks through all widgets attached + to the view. + + - can_focus: boolean, if set only searches through widget + that can be focused + """ + # XXX: recurse through containers from toplevel widget, better + # idea and will work. + widgets = widgets or self.widgets or self.__dict__.keys() + top_widget = None + for widget_name in widgets: + widget = getattr(self, widget_name) + if not isinstance(widget, gtk.Widget): + continue + if not widget.flags() & gtk.REALIZED: + # If widget isn't realized but we have a toplevel + # window, it's safe to realize it. If this check isn't + # performed, we get a crash as per + # http://bugzilla.gnome.org/show_bug.cgi?id=107872 + if isinstance(widget.get_toplevel(), gtk.Window): + widget.realize() + else: + log.warn("get_topmost_widget: widget %s was not realized" + % widget_name) + continue + if can_focus: + # Combos don't focus, but their entries do + if isinstance(widget, gtk.Combo): + widget = widget.entry + if not widget.flags() & gtk.CAN_FOCUS or \ + isinstance(widget, (gtk.Label, gtk.HSeparator, + gtk.VSeparator, gtk.Window)): + continue + + if top_widget: + allocation = widget.allocation + top_allocation = getattr(top_widget, 'allocation', None) + assert top_allocation != None + if (top_allocation[0] + top_allocation[1] > + allocation[0] + allocation[1]): + top_widget = widget + else: + top_widget = widget + return top_widget + + # + # Callback handling + # + + def _attach_callbacks(self, controller): + if self._glade_adaptor is None: + brokerclass = SignalBroker + else: + brokerclass = GladeSignalBroker + + self._broker = brokerclass(self, controller) + +# def _setup_keypress_handler(self, keypress_handler): +# # Only useful in BaseView and derived classes +# # XXX: support slaveview correctly +# log.warn("Tried to setup a keypress handler for %s " +# "but no toplevel window exists to attach to" % self) + + # + # Slave handling + # + + def attach_slave(self, name, slave): + """Attaches a slaveview to the current view, substituting the + widget specified by name. the widget specified *must* be a + eventbox; its child widget will be removed and substituted for + the specified slaveview's toplevel widget:: + + .-----------------------. the widget that is indicated in the diagram + |window/view (self.view)| as placeholder will be substituted for the + | .----------------. | slaveview's toplevel. + | | eventbox (name)| | .-----------------. + | |.--------------.| |slaveview (slave)| + | || placeholder <----. |.---------------.| + | |'--------------'| \___ toplevel || + | '----------------' | ''---------------'| + '-----------------------' '-----------------' + + the original way of attachment (naming the *child* widget + instead of the eventbox) is still supported for compatibility + reasons but will print a warning. + """ + log('%s: Attaching slave %s of type %s' % (self.__class__.__name__, + name, + slave.__class__.__name__)) + + if name in self.slaves: + # XXX: TypeError + log.warn("A slave with name %s is already attached to %r" % ( + name, self)) + self.slaves[name] = slave + + if not isinstance(slave, SlaveView): + raise TypeError("slave must be a SlaveView, not a %s" % + type(slave)) + + shell = slave.get_toplevel() + + if isinstance(shell, gtk.Window): # view with toplevel window + new_widget = shell.get_child() + shell.remove(new_widget) # remove from window to allow reparent + else: # slaveview + new_widget = shell + + # if our widgets are in a glade file get the placeholder from them + # or take it from the view itself otherwise + if self._glade_adaptor: + placeholder = self._glade_adaptor.get_widget(name) + else: + placeholder = getattr(self, name, None) + + if not placeholder: + raise AttributeError( + "slave container widget `%s' not found" % name) + parent = placeholder.get_parent() + + if slave._accel_groups: + # take care of accelerator groups; attach to parent window if we + # have one; if embedding a slave into another slave, store its + # accel groups; otherwise complain if we're dropping the + # accelerators + win = parent.get_toplevel() + if isinstance(win, gtk.Window): + # use idle_add to be sure we attach the groups as late + # as possible and avoid reattaching groups -- see + # comment in _attach_groups. + gtk.idle_add(self._attach_groups, win, slave._accel_groups) + elif isinstance(self, SlaveView): + self._accel_groups.extend(slave._accel_groups) + else: + log.warn("attached slave %s to parent %s, but parent lacked " + "a window and was not a slave view" % (slave, self)) + slave._accel_groups = [] + + # Merge the sizegroups of the slave that is being attached with the + # sizegroups of where it is being attached to. Only the sizegroups + # with the same name will be merged. + if slave._glade_adaptor: + sizegroups = slave._glade_adaptor.get_sizegroups() + for sizegroup in sizegroups: + self._merge_sizegroup(sizegroup) + + if isinstance(placeholder, gtk.EventBox): + # standard mechanism + child = placeholder.get_child() + if child is not None: + placeholder.remove(child) + placeholder.set_visible_window(False) + placeholder.add(new_widget) + elif isinstance(parent, gtk.EventBox): + # backwards compatibility + log.warn("attach_slave's api has changed: read docs, update code!") + parent.remove(placeholder) + parent.add(new_widget) + else: + raise TypeError( + "widget to be replaced must be wrapped in eventbox") + + # when attaching a slave we usually want it visible + parent.show() + # call slave's callback + slave.on_attach(self) + + slave.connect_object('validation-changed', + self._on_child__validation_changed, + name) + + for notebook in self._notebooks: + for child in notebook.get_children(): + if not shell.is_ancestor(child): + continue + + label = notebook.get_tab_label(child) + slave.connect('validation-changed', + self._on_notebook_slave__validation_changed, + name, label) + self._notebook_validation[label] = {} + + # Fire of an initial notification + slave.check_and_notify_validity(force=True) + + # return placeholder we just removed + return placeholder + + def _merge_sizegroup(self, other_sizegroup): + # Merge sizegroup from other with self that have the same name. + # Actually, no merging is being done, since the old group is preserved + + name = other_sizegroup.get_data('gazpacho::object-id') + sizegroup = getattr(self, name, None) + if not sizegroup: + return + + widgets = other_sizegroup.get_data('gazpacho::sizegroup-widgets') + if not widgets: + return + + for widget in widgets: + sizegroup.add_widget(widget) + + def detach_slave(self, name): + """ + Detatch a slave called name from view + """ + if not name in self.slaves: + raise LookupError("There is no slaved called %s attached to %r" % + (name, self)) + del self.slaves[name] + + def _attach_groups(self, win, accel_groups): + # get groups currently attached to the window; we use them + # to avoid reattaching an accelerator to the same window, which + # generates messages like: + # + # gtk-critical **: file gtkaccelgroup.c: line 188 + # (gtk_accel_group_attach): assertion `g_slist_find + # (accel_group->attach_objects, object) == null' failed. + # + # interestingly, this happens many times with notebook, + # because libglade creates and attaches groups in runtime to + # its toplevel window. + current_groups = gtk.accel_groups_from_object(win) + for group in accel_groups: + if group in current_groups: + # skip group already attached + continue + win.add_accel_group(group) + + def get_slave(self, holder): + return self.slaves.get(holder) + + + + # + # Signal connection + # + + def connect_multiple(self, widgets, signal, handler, after=False): + """ + Connect the same handler to the specified signal for a number of + widgets. + - widgets: a list of GtkWidgets + - signal: a string specifying the signals + - handler: a callback method + - after: a boolean; if TRUE, we use connect_after(), otherwise, + connect() + """ + if not isinstance(widgets, (list, tuple)): + raise TypeError("widgets must be a list, found %s" % widgets) + for widget in widgets: + if not isinstance(widget, gtk.Widget): + raise TypeError( + "Only Gtk widgets may be passed in list, found\n%s" % widget) + if after: + widget.connect_after(signal, handler) + else: + widget.connect(signal, handler) + + def disconnect_autoconnected(self): + """ + Disconnect handlers previously connected with + autoconnect_signals()""" + self._broker.disconnect_autoconnected() + + def handler_block(self, widget, signal_name=None): + # XXX: Warning, or bail out? + if not self._broker: + return + self._broker.handler_block(widget, signal_name) + + def handler_unblock(self, widget, signal_name=None): + if not self._broker: + return + self._broker.handler_unblock(widget, signal_name) + + # + # Proxies + # + + def add_proxy(self, model=None, widgets=None): + """ + Add a proxy to this view that automatically update a model when + the view changes. Arguments: + + - model. the object we are proxing. It can be None if we don't have + a model yet and we want to display the interface and set it up with + future models. + - widgets. the list of widgets that contains model attributes to be + proxied. If it is None (or not specified) it will be the whole list + of widgets this View has. + + This method return a Proxy object that you may want to use to force + updates or setting new models. Keep a reference to it since there is + no way to get that proxy later on. You have been warned (tm) + """ + log('%s: adding proxy for %s' % ( + self.__class__.__name__, + model and model.__class__.__name__)) + + widgets = widgets or self.widgets + + for widget_name in widgets: + widget = getattr(self, widget_name, None) + if widget is None: + continue + + if not IValidatableProxyWidget.providedBy(widget): + continue + + try: + widget.connect_object('validation-changed', + self._on_child__validation_changed, + widget_name) + except TypeError: + raise AssertionError("%r does not have a validation-changed " + "signal." % widget) + + proxy = Proxy(self, model, widgets) + self._proxies.append(proxy) + return proxy + + # + # Validation + # + + def _on_child__validation_changed(self, name, value): + # Children of the view, eg slaves or widgets are connected to + # this signal. When validation changes of a validatable child + # this callback is called + self._validation[name] = value + + self.check_and_notify_validity() + + def _on_notebook_slave__validation_changed(self, slave, value, name, + label): + validation = self._notebook_validation[label] + validation[name] = value + + is_valid = True + if False in validation.values(): + is_valid = False + + if is_valid: + color = color_black + else: + color = color_red + + # Only modify active state, since that's the (somewhat badly named) + # state used for the pages which are not selected. + label.modify_fg(gtk.STATE_ACTIVE, color) + label.modify_fg(gtk.STATE_NORMAL, color) + + def check_and_notify_validity(self, force=False): + # Current view is only valid if we have no invalid children + # their status are stored as values in the dictionary + is_valid = True + if False in self._validation.values(): + is_valid = False + + # Check if validation really changed + if self._valid == is_valid and force == False: + return + + self._valid = is_valid + self.emit('validation-changed', is_valid) + + # FIXME: Remove and update all callsites to use validation-changed + if self._validate_function: + self._validate_function(is_valid) + + def force_validation(self): + self.check_and_notify_validity(force=True) + + def register_validate_function(self, function): + """The signature of the validate function is: + + def function(is_valid): + + or, if it is a method: + + def function(self, is_valid): + + where the 'is_valid' parameter is True if all the widgets have + valid data or False otherwise. + """ + self._validate_function = function + +type_register(SlaveView) + +class BaseView(SlaveView): + """A view with a toplevel window.""" + + def __init__(self, toplevel=None, widgets=None, gladefile=None, + gladename=None, toplevel_name=None, domain=None, + delete_handler=None): + SlaveView.__init__(self, toplevel, widgets, gladefile, gladename, + toplevel_name, domain) + + if not isinstance(self.toplevel, gtk.Window): + raise TypeError("toplevel widget must be a Window " + "(or inherit from it),\nfound `%s' %s" + % (toplevel, self.toplevel)) + self.toplevel.set_name(self.__class__.__name__) + + if delete_handler: + id = self.toplevel.connect("delete-event", delete_handler) + if not id: + raise ValueError( + "Invalid delete handler provided: %s" % delete_handler) + + def get_glade_adaptor(self): + if not self.gladefile: + return + + return _open_glade(self, self.gladefile, self.widgets, + self.gladename, self.domain) + + # + # Hook for keypress handling + # + + def _attach_callbacks(self, controller): + super(BaseView, self)._attach_callbacks(controller) + self._setup_keypress_handler(controller.on_key_press) + + def _setup_keypress_handler(self, keypress_handler): + self.toplevel.connect_after("key_press_event", keypress_handler) + + # + # Proxying for self.toplevel + # + def set_transient_for(self, view): + """Makes the view a transient for another view; this is commonly done + for dialogs, so the dialog window is managed differently than a + top-level one. + """ + if hasattr(view, 'toplevel') and isinstance(view.toplevel, gtk.Window): + self.toplevel.set_transient_for(view.toplevel) + # In certain cases, it is more convenient to send in a window; + # for instance, in a deep slaveview hierarchy, getting the + # top view is difficult. We used to print a warning here, I + # removed it for convenience; we might want to put it back when + # http://bugs.async.com.br/show_bug.cgi?id=682 is fixed + elif isinstance(view, gtk.Window): + self.toplevel.set_transient_for(view) + else: + raise TypeError("Parameter to set_transient_for should " + "be View (found %s)" % view) + + def set_title(self, title): + """Sets the view's window title""" + self.toplevel.set_title(title) + + # + # Focus handling + # + + def get_focus_widget(self): + """Returns the currently focused widget in the window""" + return self.toplevel.focus_widget + + def check_focus(self): + """ Tests the focus in the window and prints a warning if no + widget is focused. + """ + focus = self.toplevel.focus_widget + if focus: + return + values = self.__dict__.values() + interactive = None + # Check if any of the widgets is interactive + for v in values: + if (isinstance(v, gtk.Widget) and not + isinstance(v, _non_interactive)): + interactive = v + if interactive: + log.warn("No widget is focused in view %s but you have an " + "interactive widget in it: %s""" % (self, interactive)) + + # + # Window show/hide and mainloop manipulation + # + + def hide(self, *args): + """Hide the view's window""" + self.toplevel.hide() + + def show_all(self, parent=None, *args): + self.toplevel.show_all() + self.show(parent, *args) + + def show(self, parent=None, *args): + """Show the view's window. + If the parent argument is supplied and is a valid view, this view + is set as a transient for the parent view. + """ + # Uniconize window if minimized + self.toplevel.present() # this call win.show() for us + self.check_focus() + if parent is not None: + self.set_transient_for(parent) + + def quit_if_last(self, *args): + quit_if_last(*args) + + def hide_and_quit(self, *args): + """Hides the current window and breaks the GTK+ event loop if this + is the last window. + Its method signature allows it to be used as a signal handler. + """ + self.toplevel.hide() + self.quit_if_last() + +WidgetTree = None + +def _open_glade(view, gladefile, widgets, name, domain): + global WidgetTree + if not WidgetTree: + try: + from gazpacho.loader import loader + loader # pyflakes + except ImportError, e: + if is_gazpacho_required(): + raise RuntimeError( + "Gazpacho is required, but could not be found: %s" % e) + else: + try: + from kiwi.ui.libgladeloader import LibgladeWidgetTree as WT + WidgetTree = WT + except ImportError: + raise RuntimeError("Could not find a glade parser library") + else: + from kiwi.ui.gazpacholoader import GazpachoWidgetTree as WT + WidgetTree = WT + + return WidgetTree(view, gladefile, widgets, name, domain) diff --git a/kiwi/ui/widgets/__init__.py b/kiwi/ui/widgets/__init__.py new file mode 100644 index 0000000..1c8b70f --- /dev/null +++ b/kiwi/ui/widgets/__init__.py @@ -0,0 +1,20 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# diff --git a/kiwi/ui/widgets/checkbutton.py b/kiwi/ui/widgets/checkbutton.py new file mode 100644 index 0000000..f7bf5c1 --- /dev/null +++ b/kiwi/ui/widgets/checkbutton.py @@ -0,0 +1,69 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Gustavo Rahal <gustavo@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# + +"""GtkCheckButton support for the Kiwi Framework""" + +import gtk + +from kiwi.python import deprecationwarn +from kiwi.ui.proxywidget import ProxyWidgetMixin +from kiwi.utils import PropertyObject, gsignal, type_register + +class ProxyCheckButton(PropertyObject, gtk.CheckButton, ProxyWidgetMixin): + __gtype_name__ = 'ProxyCheckButton' + + # changed allowed data types because checkbuttons can only + # accept bool values + allowed_data_types = bool, + + def __init__(self): + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self, data_type=bool) + gtk.CheckButton.__init__(self) + + gsignal('toggled', 'override') + def do_toggled(self): + self.emit('content-changed') + self.chain() + + def read(self): + return self.get_active() + + def update(self, data): + if data is None: + self.set_active(False); + return + + # No conversion to string needed, we only accept bool + self.set_active(data) + +class CheckButton(ProxyCheckButton): + def __init__(self): + deprecationwarn( + 'CheckButton is deprecated, use ProxyCheckButton instead', + stacklevel=3) + ProxyCheckButton.__init__(self) +type_register(CheckButton) diff --git a/kiwi/ui/widgets/colorbutton.py b/kiwi/ui/widgets/colorbutton.py new file mode 100644 index 0000000..7b17e78 --- /dev/null +++ b/kiwi/ui/widgets/colorbutton.py @@ -0,0 +1,46 @@ +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Ali Afshar <aafshar@gmail.com> +# + +"""ColorButton proxy for the kiwi framework""" + +import gtk + +from kiwi.ui.proxywidget import ProxyWidgetMixin +from kiwi.utils import PropertyObject, gsignal, type_register + + +class ProxyColorButton(PropertyObject, gtk.ColorButton, ProxyWidgetMixin): + __gtype_name__ = 'ProxyColorButton' + + allowed_data_types = object, + + def __init__(self, color=gtk.gdk.Color(0, 0, 0)): + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self, data_type=object) + gtk.ColorButton.__init__(self, color) + + gsignal('color-set', 'override') + def do_color_set(self): + self.emit('content-changed') + self.chain() + + def read(self): + return self.get_color() + + def update(self, data): + self.set_color(data) + + +type_register(ProxyColorButton) diff --git a/kiwi/ui/widgets/combo.py b/kiwi/ui/widgets/combo.py new file mode 100644 index 0000000..491008f --- /dev/null +++ b/kiwi/ui/widgets/combo.py @@ -0,0 +1,254 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Johan Dahlin <jdahlin@async.com.br> +# Gustavo Rahal <gustavo@async.com.br> +# Daniel Saran R. da Cunha <daniel@async.com.br> +# Evandro Vale Miquelito <evandro@async.com.br> +# + +"""GtkComboBox and GtkComboBoxEntry support for the Kiwi Framework. + +The GtkComboBox and GtkComboBoxEntry classes here are also slightly extended +they contain methods to easily insert and retrieve data from combos. +""" + +import gobject +import gtk +from gtk import keysyms + +from kiwi import ValueUnset +from kiwi.python import deprecationwarn +from kiwi.ui.comboboxentry import BaseComboBoxEntry +from kiwi.ui.comboentry import ComboEntry +from kiwi.ui.combomixin import COL_COMBO_LABEL, COMBO_MODE_STRING, \ + COMBO_MODE_DATA, COMBO_MODE_UNKNOWN, ComboMixin +from kiwi.ui.proxywidget import ProxyWidgetMixin, ValidatableProxyWidgetMixin +from kiwi.ui.widgets.entry import ProxyEntry +from kiwi.utils import PropertyObject, gproperty + +class ProxyComboBox(PropertyObject, gtk.ComboBox, ComboMixin, ProxyWidgetMixin): + + __gtype_name__ = 'ProxyComboBox' + + def __init__(self): + gtk.ComboBox.__init__(self) + ComboMixin.__init__(self) + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self) + self.connect('changed', self._on__changed) + + renderer = gtk.CellRendererText() + self.pack_start(renderer) + self.add_attribute(renderer, 'text', COL_COMBO_LABEL) + + # GtkComboBox is a GtkContainer subclass which implements __len__ in + # PyGTK in 2.8 and higher. Therefor we need to provide our own + # implementation to be backwards compatible and override the new + # behavior in 2.8 + def __len__(self): + return len(self.get_model()) + + def _on__changed(self, combo): + self.emit('content-changed') + + def read(self): + if self.mode == COMBO_MODE_UNKNOWN: + return ValueUnset + + data = self.get_selected() + if self.mode == COMBO_MODE_STRING: + data = self._from_string(data) + + return data + + def update(self, data): + # We dont need validation because the user always + # choose a valid value + + if data is None: + return + + if self.mode == COMBO_MODE_STRING: + data = self._as_string(data) + + self.select(data) + + def prefill(self, itemdata, sort=False): + ComboMixin.prefill(self, itemdata, sort) + + # we always have something selected, by default the first item + self.set_active(0) + self.emit('content-changed') + + def clear(self): + ComboMixin.clear(self) + self.emit('content-changed') + +class ProxyComboBoxEntry(PropertyObject, BaseComboBoxEntry, ComboMixin, + ValidatableProxyWidgetMixin): + __gtype_name__ = 'ProxyComboBoxEntry' + # it doesn't make sense to connect to this signal + # because we want to monitor the entry of the combo + # not the combo box itself. + + gproperty("list-editable", bool, True, "Editable") + + def __init__(self, **kwargs): + deprecationwarn( + 'ProxyComboBoxEntry is deprecated, use ProxyComboEntry instead', + stacklevel=3) + BaseComboBoxEntry.__init__(self) + ComboMixin.__init__(self) + ValidatableProxyWidgetMixin.__init__(self, widget=self.entry) + PropertyObject.__init__(self, **kwargs) + + self.set_text_column(COL_COMBO_LABEL) + + # here we connect the expose-event signal directly to the entry + self.child.connect('changed', self._on_child_entry__changed) + + # HACK! we force a queue_draw because when the window is first + # displayed the icon is not drawn. + gobject.idle_add(self.queue_draw) + + self.set_events(gtk.gdk.KEY_RELEASE_MASK) + self.connect("key-release-event", self._on__key_release_event) + + def prop_set_list_editable(self, value): + if self.mode == COMBO_MODE_DATA: + return + + self.entry.set_editable(value) + + return value + + def _update_selection(self, text=None): + if text is None: + text = self.entry.get_text() + + self.select_item_by_label(text) + + def _add_text_to_combo_list(self): + text = self.entry.get_text() + if not text.strip(): + return + + if text in self.get_model_strings(): + return + + self.entry.set_text('') + self.append_item(text) + self._update_selection(text) + + def _on__key_release_event(self, widget, event): + """Checks for "Enter" key presses and add the entry text to + the combo list if the combo list is set as editable. + """ + if not self.list_editable: + return + + if event.keyval in (keysyms.KP_Enter, + keysyms.Return): + self._add_text_to_combo_list() + + def _on_child_entry__changed(self, widget): + """Called when something on the entry changes""" + if not widget.get_text(): + return + + self.emit('content-changed') + + def set_mode(self, mode): + # If we're in the transition to go from + # unknown->label set editable to False + if (self.mode == COMBO_MODE_UNKNOWN and mode == COMBO_MODE_DATA): + self.entry.set_editable(False) + + ComboMixin.set_mode(self, mode) + + def read(self): + if self.mode == COMBO_MODE_UNKNOWN: + return ValueUnset + return self.get_selected() + + def update(self, data): + if data is ValueUnset or data is None: + self.entry.set_text("") + else: + self.select(data) + + def prefill(self, itemdata, sort=False, clear_entry=False): + ComboMixin.prefill(self, itemdata, sort) + if clear_entry: + self.entry.set_text("") + + # setup the autocompletion + auto = gtk.EntryCompletion() + auto.set_model(self.get_model()) + auto.set_text_column(COL_COMBO_LABEL) + self.entry.set_completion(auto) + + def clear(self): + """Removes all items from list and erases entry""" + ComboMixin.clear(self) + self.entry.set_text("") + +class ProxyComboEntry(PropertyObject, ComboEntry, + ValidatableProxyWidgetMixin): + __gtype_name__ = 'ProxyComboEntry' + + gproperty("list-editable", bool, True, "Editable") + + def __init__(self): + entry = ProxyEntry() + ComboEntry.__init__(self, entry=entry) + ValidatableProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self) + entry.connect('content-changed', self._on_entry__content_changed) + + # We only need to listen for changes in the entry, it's updated + # even if you select something in the popup list + def _on_entry__content_changed(self, entry): + self.emit('content-changed') + + def prop_set_list_editable(self, value): + self.entry.set_editable(value) + return value + + def read(self): + return self.get_selected() + + def update(self, data): + if data is ValueUnset or data is None: + self.entry.set_text("") + else: + self.select(data) + + def clear(self): + """Removes all items from list and erases entry""" + ComboMixin.clear(self) + self.entry.set_text("") + + def set_tooltip(self, text): + self.entry.set_tooltip(text) + diff --git a/kiwi/ui/widgets/combobox.py b/kiwi/ui/widgets/combobox.py new file mode 100644 index 0000000..7bfcc60 --- /dev/null +++ b/kiwi/ui/widgets/combobox.py @@ -0,0 +1,43 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2001-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> +# + +"""GtkComboBox and GtkComboBoxEntry support for the Kiwi Framework. +backwards compatibility layer""" + +from kiwi.ui.widgets.combo import ProxyComboBox +from kiwi.ui.widgets.combo import ProxyComboBoxEntry +from kiwi.ui.combomixin import COL_COMBO_LABEL, COL_COMBO_DATA, \ + COMBO_MODE_STRING, COMBO_MODE_DATA, COMBO_MODE_UNKNOWN + +class ComboBox(ProxyComboBox): + pass + +class ComboBoxEntry(ProxyComboBoxEntry): + pass + +# pyflakes +(COL_COMBO_LABEL, COL_COMBO_DATA, COMBO_MODE_STRING, + COMBO_MODE_DATA, COMBO_MODE_UNKNOWN) diff --git a/kiwi/ui/widgets/entry.py b/kiwi/ui/widgets/entry.py new file mode 100644 index 0000000..9ab8f0f --- /dev/null +++ b/kiwi/ui/widgets/entry.py @@ -0,0 +1,255 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# + +"""GtkEntry support for the Kiwi Framework""" + +import datetime + +import gtk + +from kiwi.datatypes import ValidationError, converter, number +from kiwi.decorators import deprecated +from kiwi.python import deprecationwarn +from kiwi.ui.entry import MaskError, KiwiEntry, ENTRY_MODE_TEXT, \ + ENTRY_MODE_DATA +from kiwi.ui.dateentry import DateEntry +from kiwi.ui.proxywidget import ValidatableProxyWidgetMixin +from kiwi.utils import PropertyObject, gsignal, type_register + +DATE_MASK_TABLE = { + '%m': '00', + '%y': '00', + '%d': '00', + '%Y': '0000', + '%H': '00', + '%M': '00', + '%S': '00', + '%T': '00:00:00', + # FIXME: locale specific + '%r': '00:00:00 LL', + } + +class ProxyEntry(KiwiEntry, ValidatableProxyWidgetMixin): + """The Kiwi Entry widget has many special features that extend the basic + gtk entry. + + First of all, as every Kiwi Widget, it implements the Proxy protocol. + As the users types the entry can interact with the application model + automatically. + Kiwi Entry also implements interesting UI additions. If the input data + does not match the data type of the entry the background nicely fades + to a light red color. As the background changes an information icon + appears. When the user passes the mouse over the information icon a + tooltip is displayed informing the user how to correctly fill the + entry. When dealing with date and float data-type the information on + how to fill these entries is displayed according to the current locale. + """ + + __gtype_name__ = 'ProxyEntry' + + def __init__(self, data_type=None): + self._block_changed = False + KiwiEntry.__init__(self) + ValidatableProxyWidgetMixin.__init__(self) + self.set_property('data-type', data_type) + + # Virtual methods + gsignal('changed', 'override') + def do_changed(self): + """Called when the content of the entry changes. + + Sets an internal variable that stores the last time the user + changed the entry + """ + + self.chain() + + self._update_current_object(self.get_text()) + self.emit('content-changed') + + def prop_set_data_type(self, data_type): + data_type = super(ProxyEntry, self).prop_set_data_type(data_type) + + # Numbers should be right aligned + if data_type and issubclass(data_type, number): + self.set_property('xalign', 1.0) + + # Apply a mask for the data types, some types like + # dates has a default mask + try: + self.set_mask_for_data_type(data_type) + except MaskError: + pass + return data_type + + # Public API + + def set_mask_for_data_type(self, data_type): + """ + @param data_type: + """ + + if not data_type in (datetime.datetime, datetime.date, datetime.time): + return + conv = converter.get_converter(data_type) + mask = conv.get_format() + + # For win32, skip mask + # FIXME: How can we figure out the real locale specific string? + if mask == '%X': + mask = '%H:%M:%S' + elif mask == '%x': + mask = '%d/%m/%Y' + elif mask == '%c': + mask = '%d/%m/%Y %H:%M:%S' + + for format_char, mask_char in DATE_MASK_TABLE.items(): + mask = mask.replace(format_char, mask_char) + + self.set_mask(mask) + + #@deprecated('prefill') + def set_completion_strings(self, strings=[], values=[]): + """ + Set strings used for entry completion. + If values are provided, each string will have an additional + data type. + + @param strings: + @type strings: list of strings + @param values: + @type values: list of values + """ + + completion = self._get_completion() + model = completion.get_model() + model.clear() + + if values: + self._mode = ENTRY_MODE_DATA + self.prefill(zip(strings, values)) + else: + self._mode = ENTRY_MODE_TEXT + self.prefill(strings) + set_completion_strings = deprecated('prefill')(set_completion_strings) + + def set_text(self, text): + """ + Sets the text of the entry + + @param text: + """ + + self._update_current_object(text) + + # If content isn't empty set_text emitts changed twice. + # Protect content-changed from being updated and issue + # a manual emission afterwards + self._block_changed = True + gtk.Entry.set_text(self, text) + self._block_changed = False + self.emit('content-changed') + + self.set_position(-1) + + def do_changed(self): + if self._block_changed: + self.emit_stop_by_name('changed') + return + self.emit('content-changed') + + # ProxyWidgetMixin implementation + + def read(self): + mode = self._mode + if mode == ENTRY_MODE_TEXT: + text = self.get_text() + try: + return self._from_string(text) + except ValidationError: + # Do not consider masks which only displays static + # characters invalid, instead return an empty string + if self.get_mask() and text == self.get_empty_mask(): + return "" + else: + raise + elif mode == ENTRY_MODE_DATA: + return self._current_object + else: + raise AssertionError + + def update(self, data): + if data is None: + text = "" + else: + mode = self._mode + if mode == ENTRY_MODE_DATA: + new = self._get_text_from_object(data) + if new is None: + raise TypeError("%r is not a data object" % data) + text = new + elif mode == ENTRY_MODE_TEXT: + text = self._as_string(data) + + self.set_text(text) + +type_register(ProxyEntry) + +class Entry(ProxyEntry): + def __init__(self, data_type=None): + deprecationwarn('Entry is deprecated, use ProxyEntry instead', + stacklevel=3) + ProxyEntry.__init__(self, data_type) +type_register(Entry) + +class ProxyDateEntry(PropertyObject, DateEntry, ValidatableProxyWidgetMixin): + __gtype_name__ = 'ProxyDateEntry' + + # changed allowed data types because checkbuttons can only + # accept bool values + allowed_data_types = datetime.date, + + def __init__(self): + DateEntry.__init__(self) + ValidatableProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self) + + gsignal('changed', 'override') + def do_changed(self): + self.chain() + self.emit('content-changed') + + # ProxyWidgetMixin implementation + + def read(self): + return self.get_date() + + def update(self, data): + if data is None: + self.entry.set_text("") + else: + self.set_date(data) + +type_register(ProxyDateEntry) diff --git a/kiwi/ui/widgets/filechooser.py b/kiwi/ui/widgets/filechooser.py new file mode 100644 index 0000000..bbfdda1 --- /dev/null +++ b/kiwi/ui/widgets/filechooser.py @@ -0,0 +1,80 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Ali Afshar <aafshar@gmail.com> +# + + +"""Filechooser widgets for the kiwi framework""" + +import gtk + +from kiwi.ui.proxywidget import ProxyWidgetMixin +from kiwi.utils import PropertyObject, gsignal + +class _FileChooserMixin(object): + """Mixin to use common methods of the FileChooser interface""" + + allowed_data_types = str, + + gsignal('selection_changed', 'override') + def do_selection_changed(self): + self.emit('content-changed') + self.chain() + + def read(self): + return self.get_filename() + + def update(self, data): + if data is None: + return + self.set_filename(data) + +class ProxyFileChooserButton(_FileChooserMixin, PropertyObject, + gtk.FileChooserButton, ProxyWidgetMixin): + __gtype_name__ = 'ProxyFileChooserButton' + def __init__(self, title=None, parent=None, + action=gtk.FILE_CHOOSER_ACTION_OPEN, + buttons=None, backend=None): + """ + @param title: + @param parent: + @param action: + @param buttons: + @param backend: + """ + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self, data_type=str) + gtk.FileChooserButton.__init__(self, title=title, + parent=parent, action=action, + buttons=buttons, backend=backend) + +class ProxyFileChooserWidget(_FileChooserMixin, PropertyObject, + gtk.FileChooserWidget, ProxyWidgetMixin): + __gtype_name__ = 'ProxyFileChooserWidget' + def __init__(self, title, backend=None): + """ + @param title: + @param backend: + """ + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self, data_type=str) + gtk.FileChooserWidget.__init__(self, title=title, backend=backend) + diff --git a/kiwi/ui/widgets/fontbutton.py b/kiwi/ui/widgets/fontbutton.py new file mode 100644 index 0000000..5852c02 --- /dev/null +++ b/kiwi/ui/widgets/fontbutton.py @@ -0,0 +1,47 @@ +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Ali Afshar <aafshar@gmail.com> +# + +"""FontButton proxy for the kiwi framework""" + +import gtk + +from kiwi.ui.proxywidget import ProxyWidgetMixin +from kiwi.utils import PropertyObject, gsignal, type_register + + +class ProxyFontButton(PropertyObject, gtk.FontButton, ProxyWidgetMixin): + __gtype_name__ = 'ProxyFontButton' + + allowed_data_types = str, + + def __init__(self, fontname=None): + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self, data_type=str) + gtk.FontButton.__init__(self, fontname) + + gsignal('font-set', 'override') + def do_font_set(self): + self.emit('content-changed') + self.chain() + + def read(self): + return self.get_font_name() + + def update(self, data): + self.set_font_name(data) + + +type_register(ProxyFontButton) + diff --git a/kiwi/ui/widgets/label.py b/kiwi/ui/widgets/label.py new file mode 100644 index 0000000..b91cc76 --- /dev/null +++ b/kiwi/ui/widgets/label.py @@ -0,0 +1,157 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# + +"""GtkLabel support for the Kiwi Framework + +The L{Label} is also extended to support some basic markup like +L{Label.set_bold}""" + +import gtk + +from kiwi.python import deprecationwarn +from kiwi.ui.gadgets import set_foreground +from kiwi.ui.proxywidget import ProxyWidgetMixin +from kiwi.utils import PropertyObject, type_register + +class ProxyLabel(PropertyObject, gtk.Label, ProxyWidgetMixin): + __gtype_name__ = 'ProxyLabel' + def __init__(self, label='', data_type=None): + """ + @param label: initial text + @param data_type: data type of label + """ + gtk.Label.__init__(self, label) + PropertyObject.__init__(self, data_type=data_type) + ProxyWidgetMixin.__init__(self) + self.set_use_markup(True) + self._attr_dic = { "style": None, + "weight": None, + "size": None, + "underline": None} + self._size_list = ('xx-small', 'x-small', + 'small', 'medium', + 'large', 'x-large', + 'xx-large') + + self.connect("notify::label", self._on_label_changed) + + def _on_label_changed(self, label, param): + # Since most of the time labels do not have a model attached to it + # we should just emit a signal if a model is defined + if self.model_attribute: + self.emit('content-changed') + + def read(self): + return self._from_string(self.get_text()) + + def update(self, data): + if data is None: + text = "" + else: + text = self._as_string(data) + self.set_text(text) + + def _apply_attributes(self): + # sorting is been done so we can be sure of the order of the + # attributes. Helps writing tests cases + attrs = self._attr_dic + keys = attrs.keys() + keys.sort() + + attr_pairs = ['%s="%s"' % (key, attrs[key]) for key in keys + if attrs[key]] + self.set_markup('<span %s>%s</span>' % (' '.join(attr_pairs), + self.get_text())) + + def _set_text_attribute(self, attribute_name, attr, value): + if value: + if self._attr_dic[attribute_name] is None: + self._attr_dic[attribute_name] = attr + self._apply_attributes() + else: + if self._attr_dic[attribute_name] is not None: + self._attr_dic[attribute_name] = None + self._apply_attributes() + + def set_bold(self, value): + """ If True set the text to bold. False sets the text to normal """ + self._set_text_attribute("weight", "bold", value) + + def set_italic(self, value): + """ Enable or disable italic text + @param value: Allowed values: + - True: enable Italic attribute + - False: disable Italic attribute + """ + self._set_text_attribute("style", "italic", value) + + def set_underline(self, value): + """ Enable or disable underlined text + @param value: Allowed values: + - True: enable Underline attribute + - Fase: disable Underline attribute + """ + self._set_text_attribute("underline", "single", value) + + def set_size(self, size=None): + """ Set the size of the label. If size is empty the label will be + set to the default size. + @param size: Allowed values: + - xx-small + - x-small + - small + - medium, + - large + - x-large + - xx-large + @type size: string + """ + if (size is not None and + size not in self._size_list): + raise ValueError('Size of "%s" label is not valid' % + self.get_text()) + + self._attr_dic["size"] = size + self._apply_attributes() + + def set_text(self, text): + """ Overrides gtk.Label set_text method. Sets the new text of + the label but keeps the formating + @param text: label + @type text: string + """ + gtk.Label.set_text(self, text) + self._apply_attributes() + + def set_color(self, color): + set_foreground(self, color) +type_register(ProxyLabel) + +class Label(ProxyLabel): + def __init__(self, label='', data_type=None): + deprecationwarn( + 'Label is deprecated, use ProxyLabel instead', + stacklevel=3) + ProxyLabel.__init__(self, label=label, data_type=data_type) +type_register(Label) diff --git a/kiwi/ui/widgets/list.py b/kiwi/ui/widgets/list.py new file mode 100644 index 0000000..e57d412 --- /dev/null +++ b/kiwi/ui/widgets/list.py @@ -0,0 +1,62 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2001-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Johan Dahlin <jdahlin@async.com.br> +# + +"""High level wrapper for GtkTreeView: backwards compatibility layer""" + +import gtk + +from kiwi.decorators import deprecated +from kiwi.python import deprecationwarn +from kiwi.ui.objectlist import Column, SequentialColumn, ColoredColumn, \ + ListLabel, SummaryLabel +from kiwi.ui.objectlist import ObjectList, log + +# pyflakes +Column, SequentialColumn, ColoredColumn, ListLabel, SummaryLabel + +class List(ObjectList): + def __init__(self, columns=[], + instance_list=None, + mode=gtk.SELECTION_BROWSE): + deprecationwarn( + 'List is deprecated, use ObjectList instead', + stacklevel=3) + ObjectList.__init__(self, columns, instance_list, mode) + + # Backwards compat + def add_instance(self, *args, **kwargs): + return self.append(*args, **kwargs) + add_instance = deprecated('append', log)(add_instance) + + def remove_instance(self, *args, **kwargs): + return self.remove(*args, **kwargs) + remove_instance = deprecated('remove', log)(remove_instance) + + def update_instance(self, *args, **kwargs): + return self.update(*args, **kwargs) + update_instance = deprecated('update', log)(update_instance) + + def select_instance(self, *args, **kwargs): + return self.select(*args, **kwargs) + select_instance = deprecated('select', log)(select_instance) + diff --git a/kiwi/ui/widgets/radiobutton.py b/kiwi/ui/widgets/radiobutton.py new file mode 100644 index 0000000..147613f --- /dev/null +++ b/kiwi/ui/widgets/radiobutton.py @@ -0,0 +1,94 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Daniel Saran R. da Cunha <daniel@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Gustavo Rahal <gustavo@async.com.br> +# + +"""GtkRadioButton support for the Kiwi Framework""" + +import gtk + +from kiwi import ValueUnset +from kiwi.python import deprecationwarn +from kiwi.utils import PropertyObject, gproperty, type_register +from kiwi.ui.proxywidget import ProxyWidgetMixin + +class ProxyRadioButton(PropertyObject, gtk.RadioButton, ProxyWidgetMixin): + __gtype_name__ = 'ProxyRadioButton' + gproperty('data-value', str, nick='Data Value') + + def __init__(self, group=None, label=None, use_underline=True): + gtk.RadioButton.__init__(self, None, label, use_underline) + if group: + self.set_group(group) + ProxyWidgetMixin.__init__(self) + PropertyObject.__init__(self) + self.connect('group-changed', self._on_group_changed) + + def _on_radio__toggled(self, radio): + self.emit('content-changed') + + def _on_group_changed(self, radio): + for radio in radio.get_group(): + radio.connect('toggled', self._on_radio__toggled) + + def get_selected(self): + """ + Get the currently selected radiobutton. + + @returns: The selected L{RadioButton} or None if there are no + selected radiobuttons. + """ + + for button in self.get_group(): + if button.get_active(): + return button + + def read(self): + button = self.get_selected() + if button is None: + return ValueUnset + + return self._from_string(button.data_value) + + def update(self, data): + if data is None: + # In a group of radiobuttons, the only widget which is in + # the proxy is ourself, the other buttons do not get their + # update() method called, so the default value is activate + # ourselves when the model is empty + self.set_active(True) + return + + data = self._as_string(data) + for rb in self.get_group(): + if rb.get_property('data-value') == data: + rb.set_active(True) + +class RadioButton(ProxyRadioButton): + def __init__(self): + deprecationwarn( + 'RadioButton is deprecated, use ProxyRadioButton instead', + stacklevel=3) + ProxyRadioButton.__init__(self) +type_register(RadioButton) diff --git a/kiwi/ui/widgets/spinbutton.py b/kiwi/ui/widgets/spinbutton.py new file mode 100644 index 0000000..1c7c78e --- /dev/null +++ b/kiwi/ui/widgets/spinbutton.py @@ -0,0 +1,124 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Gustavo Rahal <gustavo@async.com.br> +# Lorenzo Gil Sanchez <lgs@sicem.biz> +# Evandro Vale Miquelito <evandro@async.com.br> +# + +"""GtkSpinButton support for the Kiwi Framework + +L{SpinButton} is also enhanced to display an icon using +L{kiwi.ui.icon.IconEntry} +""" + +import gtk + +from kiwi.datatypes import number +from kiwi.python import deprecationwarn +from kiwi.ui.icon import IconEntry +from kiwi.ui.proxywidget import ValidatableProxyWidgetMixin +from kiwi.utils import PropertyObject, gsignal, type_register + +class ProxySpinButton(PropertyObject, gtk.SpinButton, ValidatableProxyWidgetMixin): + """ + A SpinButton subclass which adds supports for the Kiwi Framework. + This widget supports validation + The only allowed types for spinbutton are int and float. + + """ + __gtype_name__ = 'ProxySpinButton' + allowed_data_types = number + + def __init__(self): + # since the default data_type is str we need to set it to int + # or float for spinbuttons + gtk.SpinButton.__init__(self) + PropertyObject.__init__(self, data_type=int) + ValidatableProxyWidgetMixin.__init__(self) + self._icon = IconEntry(self) + self.set_property('xalign', 1.0) + + gsignal('changed', 'override') + def do_changed(self): + """Called when the content of the spinbutton changes. + """ + # This is a work around, because GtkEditable.changed is called too + # often, as reported here: http://bugzilla.gnome.org/show_bug.cgi?id=64998 + if self.get_text() != '': + self.emit('content-changed') + self.chain() + + def read(self): + return self._from_string(self.get_text()) + + def update(self, data): + if data is None: + self.set_text("") + else: + # set_value accepts a float or int, no as_string conversion needed, + # and since we accept only int and float just send it in. + self.set_value(data) + + def do_expose_event(self, event): + # This gets called when any of our three windows needs to be redrawn + gtk.SpinButton.do_expose_event(self, event) + + if event.window == self.window: + self._icon.draw_pixbuf() + + gsignal('size-allocate', 'override') + def do_size_allocate(self, allocation): + + self.chain(allocation) + + if self.flags() & gtk.REALIZED: + self._icon.resize_windows() + + def do_realize(self): + gtk.SpinButton.do_realize(self) + self._icon.construct() + + def do_unrealize(self): + self._icon.deconstruct() + gtk.SpinButton.do_unrealize(self) + + # IconEntry + + def set_tooltip(self, text): + self._icon.set_tooltip(text) + + def set_pixbuf(self, pixbuf): + self._icon.set_pixbuf(pixbuf) + + def update_background(self, color): + self._icon.update_background(color) + + def get_icon_window(self): + return self._icon.get_icon_window() + +class SpinButton(ProxySpinButton): + def __init__(self): + deprecationwarn( + 'SpinButton is deprecated, use ProxySpinButton instead', + stacklevel=3) + ProxySpinButton.__init__(self) +type_register(SpinButton) diff --git a/kiwi/ui/widgets/textview.py b/kiwi/ui/widgets/textview.py new file mode 100644 index 0000000..d35b945 --- /dev/null +++ b/kiwi/ui/widgets/textview.py @@ -0,0 +1,70 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2003-2005 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Christian Reis <kiko@async.com.br> +# Gustavo Rahal <gustavo@async.com.br> +# Evandro Vale Miquelito <evandro@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> + +"""GtkTextView support for the Kiwi Framework""" + +import gtk + +from kiwi.python import deprecationwarn +from kiwi.ui.proxywidget import ValidatableProxyWidgetMixin +from kiwi.utils import PropertyObject, type_register + +class ProxyTextView(PropertyObject, gtk.TextView, ValidatableProxyWidgetMixin): + __gtype_name__ = 'ProxyTextView' + def __init__(self): + gtk.TextView.__init__(self) + PropertyObject.__init__(self, data_type=str) + ValidatableProxyWidgetMixin.__init__(self) + + self._textbuffer = gtk.TextBuffer() + self._textbuffer.connect('changed', + self._on_textbuffer__changed) + self.set_buffer(self._textbuffer) + + def _on_textbuffer__changed(self, textbuffer): + self.emit('content-changed') + self.read() + + def read(self): + textbuffer = self._textbuffer + data = textbuffer.get_text(textbuffer.get_start_iter(), + textbuffer.get_end_iter()) + return self._from_string(data) + + def update(self, data): + if data is None: + text = "" + else: + text = self._as_string(data) + + self._textbuffer.set_text(text) + +class TextView(ProxyTextView): + def __init__(self): + deprecationwarn( + 'TextView is deprecated, use ProxyTextView instead', + stacklevel=3) + ProxyTextView.__init__(self) +type_register(TextView) diff --git a/kiwi/ui/wizard.py b/kiwi/ui/wizard.py new file mode 100644 index 0000000..26e72b8 --- /dev/null +++ b/kiwi/ui/wizard.py @@ -0,0 +1,236 @@ +# +# Kiwi: a Framework and Enhanced Widgets for Python +# +# Copyright (C) 2005-2006 Async Open Source +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA +# +# Author(s): Gustavo Rahal <gustavo@async.com.br> +# Evandro Vale Miquelito <evandro@async.com.br> +# Johan Dahlin <jdahlin@async.com.br> +# + +import gettext + +import gtk + +from kiwi.ui.delegates import Delegate + +_ = lambda m: gettext.dgettext('kiwi', m) + +class WizardStep: + """ This class must be inherited by the steps """ + def __init__(self, previous=None, header=None): + self.previous = previous + self.header = header + + def next_step(self): + # This is a virtual method, which must be redefined on children + # classes. It should not be called by the last step (in this case, + # has_next_step should return 0). + raise NotImplementedError + + def post_init(self): + """A virtual method that must be defined on child when it's + necessary. This method will be called right after the change_step + method on PluggableWizard is concluded for the current step. + """ + + def has_next_step(self): + # This method should return False on last step classes + return True + + def has_previous_step(self): + # This method should return False on first step classes; since + # self.previous is normally None for them, we can get away with + # this simplified check. Redefine as necessary. + return self.previous is not None + + def previous_step(self): + return self.previous + + def validate_step(self): + """A hook called always when changing steps. If it returns False + we can not go forward. + """ + return True + +class PluggableWizard(Delegate): + """ Wizard controller and view class """ + gladefile = 'PluggableWizard' + retval = None + + def __init__(self, title, first_step, size=None, edit_mode=False): + """ + @param title: + @param first_step: + @param size: + @param edit_mode: + """ + Delegate.__init__(self, delete_handler=self.quit_if_last, + gladefile=self.gladefile, + widgets=self.widgets) + if not isinstance(first_step, WizardStep): + raise TypeError("first_step must be a WizardStep instance") + + self.set_title(title) + self._current = None + self._first_step = first_step + self.edit_mode = edit_mode + if size: + self.get_toplevel().set_default_size(size[0], size[1]) + + self._change_step(first_step) + if not self.edit_mode: + self.ok_button.hide() + + # Callbacks + + def on_next_button__clicked(self, button): + if not self._current.validate_step(): + return + + if not self._current.has_next_step(): + # This is the last step + self._change_step() + return + + self._change_step(self._current.next_step()) + + def on_ok_button__clicked(self, button): + self._change_step() + + def on_previous_button__clicked(self, button): + self._change_step(self._current.previous_step()) + + def on_cancel_button__clicked(self, button): + self.cancel() + + # Private API + + def _change_step(self, step=None): + if step is None: + # This is the last step and we can finish the job here + self.finish() + return + step.show() + self._current = step + self._refresh_slave() + if step.header: + self.header_lbl.show() + self.header_lbl.set_text(step.header) + else: + self.header_lbl.hide() + self.update_view() + self._current.post_init() + + def _refresh_slave(self): + holder_name = 'slave_area' + if self.get_slave(holder_name): + self.detach_slave(holder_name) + self.attach_slave(holder_name, self._current) + + def _show_first_page(self): + self.enable_next() + self.disable_back() + self.disable_finish() + self.notification_lbl.hide() + + def _show_page(self): + self.enable_back() + self.enable_next() + self.disable_finish() + self.notification_lbl.hide() + + def _show_last_page(self): + self.enable_back() + self.notification_lbl.show() + if self.edit_mode: + self.disable_next() + else: + self.enable_next() + self.enable_finish() + + # Public API + def update_view(self): + if self.edit_mode: + self.ok_button.set_sensitive(True) + + if not self._current.has_previous_step(): + self._show_first_page() + elif self._current.has_next_step(): + self._show_page() + else: + self._show_last_page() + + def enable_next(self): + """ + Enables the next button in the wizard. + """ + self.next_button.set_sensitive(True) + + def disable_next(self): + """ + Enables the next button in the wizard. + """ + self.next_button.set_sensitive(False) + + def enable_back(self): + """ + Enables the back button in the wizard. + """ + self.previous_button.set_sensitive(True) + + def disable_back(self): + """ + Enables the back button in the wizard. + """ + self.previous_button.set_sensitive(False) + + def enable_finish(self): + """ + Enables the finish button in the wizard. + """ + if self.edit_mode: + button = self.ok_button + else: + button = self.next_button + button.set_label(_('Finish')) + + def disable_finish(self): + """ + Disables the finish button in the wizard. + """ + if self.edit_mode: + self.ok_button.set_label(gtk.STOCK_OK) + else: + self.next_button.set_label(gtk.STOCK_GO_FORWARD) + + def set_message(self, message): + """ + @param message: + """ + self.notification_lbl.set_text(message) + + def cancel(self, *args): + # Redefine this method if you want something done when cancelling the + # wizard. + self.retval = None + + def finish(self): + # Redefine this method if you want something done when finishing the + # wizard. + pass |