diff --git a/gramps2/ChangeLog b/gramps2/ChangeLog index 17c821e61..1a2198fc1 100644 --- a/gramps2/ChangeLog +++ b/gramps2/ChangeLog @@ -1,3 +1,8 @@ +2007-01-04 Zsolt Foldvari + * src/GrampsWidgets.py: Add ValidatableMaskedEntry what extends + the MaskedEntry with validation feature + * TODO: kiwi entry removed + 2007-01-03 Don Allingham * src/DataViews/_EventView.py: lint fixes * src/DataViews/_PersonView.py: lint fixes diff --git a/gramps2/TODO b/gramps2/TODO index 577899a47..92d799a67 100644 --- a/gramps2/TODO +++ b/gramps2/TODO @@ -12,12 +12,6 @@ necessarily need multiple notes. Determine which ones should and shouldn't. -* Adapt the kiwi-entry widget from the Kiwi project so that GRAMPS can - use it. It looks like it can be broken out from the core of the - Kiwi project. This would give us filtered input, icons in the entry, - and shading of the box. All of which would be good to help restrict - input to valid input. - * Date calculator. See http://sourceforge.net/mailarchive/forum.php?thread_id=3252078&forum_id=1993 diff --git a/gramps2/src/GrampsWidgets.py b/gramps2/src/GrampsWidgets.py index 1e58f06b4..eb0b42c23 100644 --- a/gramps2/src/GrampsWidgets.py +++ b/gramps2/src/GrampsWidgets.py @@ -24,6 +24,7 @@ import cgi import os import cPickle as pickle from gettext import gettext as _ +import string #------------------------------------------------------------------------- # @@ -652,19 +653,142 @@ class PlaceEntry: self.tooltips.set_tip(self.share, _('Select an existing place')) self.tooltips.set_tip(self.add_del, _('Add a new place')) -##============================================================================## +#============================================================================ +# +# MaskedEntry and ValidatableMaskedEntry copied and merged from the Kiwi +# project's ValidatableProxyWidgetMixin, KiwiEntry and ProxyEntry. +# +# http://www.async.com.br/projects/kiwi +# +#============================================================================ -DEFAULT_DELAY = 500 -BORDER_WIDTH = 4 +class MaskError(Exception): + pass + +class ValidationError(Exception): + pass + +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. + """ + __gsignals__ = { + 'done': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ()), + 'color-changed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + (gtk.gdk.Color,)), + } + + # How long time it'll take before we start (in ms) + COMPLAIN_DELAY = 500 + + MERGE_COLORS_DELAY = 100 + + ERROR_COLOR = "#ffd5d5" + + def __init__(self, widget): + gobject.GObject.__init__(self) + self._widget = widget + self._start_color = None + 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)) + + rs, gs, bs = src_color.red, src_color.green, src_color.blue + rd, gd, bd = dst_color.red, dst_color.green, dst_color.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 = gtk.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(self._start_color, + gtk.gdk.color_parse(FadeOut.ERROR_COLOR)).next + self._background_timeout_id = ( + gobject.timeout_add(FadeOut.MERGE_COLORS_DELAY, func)) + self._countdown_timeout_id = -1 + + def start(self, color): + """Schedules a start of the countdown. + @param color: initial background color + @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._start_color = color + ##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(self._start_color) + self._done = False + +if gtk.pygtk_version < (2,8,0): + gobject.type_register(FadeOut) class Tooltip(gtk.Window): + '''Tooltip for the Icon in the MaskedEntry''' + + DEFAULT_DELAY = 500 + BORDER_WIDTH = 4 + 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.set_border_width(Tooltip.BORDER_WIDTH) self.connect('expose-event', self._on__expose_event) self._label = gtk.Label() @@ -684,7 +808,7 @@ class Tooltip(gtk.Window): y += widget.allocation.y x = screen.get_root_window().get_pointer()[0] - x -= (w / 2 + BORDER_WIDTH) + x -= (w / 2 + Tooltip.BORDER_WIDTH) pointer_screen, px, py, _ = screen.get_display().get_pointer() if pointer_screen != screen: @@ -699,11 +823,11 @@ class Tooltip(gtk.Window): elif x < monitor.x: x = monitor.x - if ((y + h + widget.allocation.height + BORDER_WIDTH) > + if ((y + h + widget.allocation.height + Tooltip.BORDER_WIDTH) > monitor.y + monitor.height): - y = y - h - BORDER_WIDTH + y = y - h - Tooltip.BORDER_WIDTH else: - y = y + widget.allocation.height + BORDER_WIDTH + y = y + widget.allocation.height + Tooltip.BORDER_WIDTH return x, y @@ -739,13 +863,10 @@ class Tooltip(gtk.Window): if self._show_timeout_id != -1: return - self._show_timeout_id = gobject.timeout_add(DEFAULT_DELAY, + self._show_timeout_id = gobject.timeout_add(Tooltip.DEFAULT_DELAY, self._real_display, widget) -##============================================================================## -##============================================================================## - # 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 @@ -995,14 +1116,10 @@ class IconEntry(object): else: self._pos = gtk.POS_RIGHT -##============================================================================## - -import string - HAVE_2_6 = gtk.pygtk_version[:2] == (2, 6) (DIRECTION_LEFT, DIRECTION_RIGHT) = (1, -1) - + (INPUT_ASCII_LETTER, INPUT_ALPHA, INPUT_ALPHANUMERIC, @@ -1028,32 +1145,19 @@ INPUT_CHAR_MAP = { INPUT_DIGIT: unicode.isdigit, } -(ENTRY_MODE_UNKNOWN, - ENTRY_MODE_TEXT, - ENTRY_MODE_DATA) = range(3) - -##def type_register(gtype): - ##"""Register the type, but only if it's not already registered - ##@param gtype: the class to register - ##""" - - ### copied from gobjectmodule.c:_wrap_type_register - ##if (getattr(gtype, '__gtype__', None) != - ##getattr(gtype.__base__, '__gtype__', None)): - ##return False - - ##gobject.type_register(gtype) - - ##return True +(COL_TEXT, + COL_OBJECT) = range(2) class MaskedEntry(gtk.Entry): """ + The MaskedEntry is a Entry subclass with the following additions: + + - Mask, force the input to meet certain requirements + - IconEntry, allows you to have an icon inside the entry """ __gtype_name__ = 'MaskedEntry' def __init__(self): - ##self._completion = None - gtk.Entry.__init__(self) self.connect('insert-text', self._on_insert_text) @@ -1069,10 +1173,9 @@ class MaskedEntry(gtk.Entry): self.connect('notify::cursor-position', self._on_notify_cursor_position) + self._completion = None + self._exact_completion = False self._block_changed = False - - self._current_object = None - ##self._mode = ENTRY_MODE_TEXT self._icon = IconEntry(self) # List of validators @@ -1124,18 +1227,6 @@ class MaskedEntry(gtk.Entry): self._icon.deconstruct() gtk.Entry.do_unrealize(self) - # Public API - def set_text(self, text): - completion = self.get_completion() - - ##if isinstance(completion, KiwiEntryCompletion): - ##self.handler_block(completion.changed_id) - - gtk.Entry.set_text(self, text) - - ##if isinstance(completion, KiwiEntryCompletion): - ##self.handler_unblock(completion.changed_id) - # Mask & Fields def set_mask(self, mask): @@ -1201,7 +1292,9 @@ class MaskedEntry(gtk.Entry): return self._mask def get_field_text(self, field): - assert self._mask + if not self._mask: + raise MaskError("a mask must be set before calling get_field_text") + #assert self._mask text = self.get_text() start, end = self._mask_fields[field] return text[start: end].strip() @@ -1218,7 +1311,9 @@ class MaskedEntry(gtk.Entry): @returns: fields @rtype: list of strings """ - assert self._mask + if not self._mask: + raise MaskError("a mask must be set before calling get_fields") + #assert self._mask fields = [] @@ -1384,6 +1479,23 @@ class MaskedEntry(gtk.Entry): return None + 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) + def is_empty(self): text = self.get_text() if self._mask: @@ -1427,6 +1539,47 @@ class MaskedEntry(gtk.Entry): return True + 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 get_completion(self): + return self._completion + + def set_completion(self, completion): + gtk.Entry.set_completion(self, completion) + # FIXME objects not supported yet, should it be at all? + #completion.set_model(gtk.ListStore(str, object)) + completion.set_model(gtk.ListStore(str)) + completion.set_text_column(0) + self._completion = gtk.Entry.get_completion(self) + self.set_exact_completion(self._exact_completion) + return + + def _completion_exact_match_func(self, completion, key, iter): + model = completion.get_model() + if not len(model): + return + + content = model[iter][COL_TEXT] + return key.startswith(content) + + def _completion_normal_match_func(self, completion, key, iter): + model = completion.get_model() + if not len(model): + return + + content = model[iter][COL_TEXT].lower() + return key.lower() in content + def _appers_later(self, char, start): """ Check if a char appers later on the mask. If it does, return @@ -1779,32 +1932,326 @@ class MaskedEntry(gtk.Entry): def get_icon_window(self): return self._icon.get_icon_window() -#type_register(MaskedEntry) -gobject.type_register(MaskedEntry) + # Combo + + def prefill(self, itemdata, sort=False): + 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 + + values = {} + if sort: + itemdata.sort() + + for item in itemdata: + if item in values: + raise KeyError("Tried to insert duplicate value " + "%r into the entry" % item) + else: + values[item] = None + + model.append((item,)) + +if gtk.pygtk_version < (2,8,0): + gobject.type_register(MaskedEntry) + +#number = (int, float, long) + +VALIDATION_ICON_WIDTH = 16 +MANDATORY_ICON = gtk.STOCK_INFO +ERROR_ICON = gtk.STOCK_STOP + +class ValidatableMaskedEntry(MaskedEntry): + """It extends the MaskedEntry with validation feature. + + Merged from Kiwi's ValidatableProxyWidgetMixin and ProxyEntry + """ + + __gtype_name__ = 'ValidatableMaskedEntry' + + __gsignals__ = { + 'content-changed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ()), + 'validation-changed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + (gobject.TYPE_BOOLEAN,)), + 'validate': (gobject.SIGNAL_RUN_LAST, + gobject.TYPE_PYOBJECT, + (gobject.TYPE_PYOBJECT,)), + 'changed': 'override', + } + + __gproperties__ = { + 'data-type': (gobject.TYPE_PYOBJECT, + 'Data Type of the widget', + 'Type object', + gobject.PARAM_READWRITE), + 'mandatory': (gobject.TYPE_BOOLEAN, + 'Mandatory', + 'Mandatory', + False, + gobject.PARAM_READWRITE), + } + + # FIXME put the data type support back + #allowed_data_types = (basestring, datetime.date, datetime.time, + #datetime.datetime, object) + number + + def __init__(self, data_type=None): + self.data_type = None + self.mandatory = False + + MaskedEntry.__init__(self) + + self._block_changed = False + self._valid = True + self._fade = FadeOut(self) + self._fade.connect('color-changed', self._on_fadeout__color_changed) + + # FIXME put data type support back + #self.set_property('data-type', data_type) + + # Virtual methods + def do_changed(self): + if self._block_changed: + self.emit_stop_by_name('changed') + return + self.emit('content-changed') + self.validate() + + def do_get_property(self, prop): + '''Return the gproperty's value.''' + + if prop.name == 'data-type': + return self.data_type + elif prop.name == 'mandatory': + return self.mandatory + else: + raise AttributeError, 'unknown property %s' % prop.name + + def do_set_property(self, prop, value): + '''Set the property of writable properties.''' + + if prop.name == 'data-type': + if value is None: + self.data_type = value + return + + # FIXME put the data type support back + #if not issubclass(value, 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]), + #value)) + self.data_type = value + elif prop.name == 'mandatory': + self.mandatory = value + else: + raise AttributeError, 'unknown or read only property %s' % prop.name + + # 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 + """ + + # If we're not visible or sensitive return a blank value, except + # when forcing the validation + if not force and (not self.get_property('visible') or + not self.get_property('sensitive')): + return None + + try: + text = self.get_text() + ##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 self.is_empty(): + self.set_blank() + return None + else: + if self._completion: + for row in self.get_completion().get_model(): + if row[COL_TEXT] == text: + break + else: + if text: + raise ValidationError() + else: + if not self.is_empty(): + # this signal calls the on_widgetname__validate method + # of the view class and gets the exception (if any). + error = self.emit("validate", text) + if error: + raise error + + self.set_valid() + return text + except ValidationError, e: + self.set_invalid(str(e)) + return None + + 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 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.get_text() + + self.set_tooltip(text) + + if not fade: + self.set_stock(ERROR_ICON) + self.update_background(gtk.gdk.color_parse(self._fade.ERROR_COLOR)) + return + + # 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_stock(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.get_background()): + 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.set_stock(MANDATORY_ICON) + self.queue_draw() + self.set_tooltip(_('This field is mandatory')) + self._fade.stop() + valid = False + else: + valid = True + + self._set_valid_state(valid) + + def set_text(self, text): + """ + Sets the text of the entry + + @param 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 + MaskedEntry.set_text(self, text) + self._block_changed = False + self.emit('content-changed') + + self.set_position(-1) + + # Private + + def _set_valid_state(self, state): + """Updates the validation state and emits a signal if it changed""" + + if self._valid == state: + return + + self.emit('validation-changed', state) + self._valid = state + + # Callbacks + + def _on_fadeout__color_changed(self, fadeout, color): + self.update_background(color) + +if gtk.pygtk_version < (2,8,0): + gobject.type_register(ValidatableMaskedEntry) -##============================================================================## def main(args): + from RelLib import Date + from DateHandler import parser + + def on_validate(widget, text): + myDate = parser.parse(text) + if not myDate.is_regular(): + return ValidationError("This is not a proper date value") + win = gtk.Window() - win.set_title('gtk.Entry subclass') + win.set_title('ValidatableMaskedEntry test window') + win.set_position(gtk.WIN_POS_CENTER) def cb(window, event): - #print 'fields', widget.get_field_text() gtk.main_quit() win.connect('delete-event', cb) - widget = MaskedEntry() - widget.set_mask('000.000.000.000') + vbox = gtk.VBox() + win.add(vbox) -# pixbuf = gtk.gdk.pixbuf_new_from_file("images/stock_lock.png") -# widget.set_pixbuf(pixbuf) - widget.set_stock(gtk.STOCK_NO) - widget.set_tooltip("Tooltip example") - - win.add(widget) + label = gtk.Label('Pre-filled entry validated against the given list:') + vbox.pack_start(label) + + widget1 = ValidatableMaskedEntry(str) + widget1.prefill(('Birth', 'Death', 'Conseption')) + vbox.pack_start(widget1, fill=False) + + label = gtk.Label('Mandatory masked entry validated against user function:') + vbox.pack_start(label) + + widget2 = ValidatableMaskedEntry(str) + widget2.set_mask('00/00/0000') + widget2.connect('validate', on_validate) + widget2.mandatory = True + vbox.pack_start(widget2, fill=False) win.show_all() - - widget.select_region(0, 0) gtk.main() if __name__ == '__main__':