1287 lines
40 KiB
Python
1287 lines
40 KiB
Python
#
|
|
# Gramps - a GTK+/GNOME based genealogy program
|
|
#
|
|
# Copyright (C) 2007-2008 Zsolt Foldvari
|
|
# Copyright (C) 2012 Benny Malengier
|
|
#
|
|
# This program is free software; you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation; 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 General Public License
|
|
# along with this program; if not, write to the Free Software
|
|
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
|
#
|
|
|
|
__all__ = ["MaskedEntry", "ValidatableMaskedEntry"]
|
|
|
|
#-------------------------------------------------------------------------
|
|
#
|
|
# Standard python modules
|
|
#
|
|
#-------------------------------------------------------------------------
|
|
from gramps.gen.const import GRAMPS_LOCALE as glocale
|
|
_ = glocale.translation.gettext
|
|
import string
|
|
import sys
|
|
|
|
import logging
|
|
_LOG = logging.getLogger(".widgets.validatedmaskedentry")
|
|
|
|
#-------------------------------------------------------------------------
|
|
#
|
|
# GTK/Gnome modules
|
|
#
|
|
#-------------------------------------------------------------------------
|
|
from gi.repository import GObject
|
|
from gi.repository import GLib
|
|
from gi.repository import Gdk
|
|
from gi.repository import Gtk
|
|
from gi.repository import Pango
|
|
|
|
#-------------------------------------------------------------------------
|
|
#
|
|
# Gramps modules
|
|
#
|
|
#-------------------------------------------------------------------------
|
|
from gramps.gen.errors import MaskError, ValidationError, WindowActiveError
|
|
from .undoableentry import UndoableEntry
|
|
|
|
#============================================================================
|
|
#
|
|
# MaskedEntry and ValidatableMaskedEntry copied and merged from the Kiwi
|
|
# project's ValidatableProxyWidgetMixin, KiwiEntry and ProxyEntry.
|
|
#
|
|
# http://www.async.com.br/projects/kiwi
|
|
#
|
|
#============================================================================
|
|
|
|
(DIRECTION_LEFT, DIRECTION_RIGHT) = (1, -1)
|
|
|
|
(INPUT_ASCII_LETTER,
|
|
INPUT_ALPHA,
|
|
INPUT_ALPHANUMERIC,
|
|
INPUT_DIGIT) = list(range(4))
|
|
|
|
INPUT_FORMATS = {
|
|
'0': INPUT_DIGIT,
|
|
'L': INPUT_ASCII_LETTER,
|
|
'A': INPUT_ALPHANUMERIC,
|
|
'a': INPUT_ALPHANUMERIC,
|
|
'&': INPUT_ALPHA,
|
|
}
|
|
|
|
# Todo list: Other useful 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: str.isalpha,
|
|
INPUT_ALPHANUMERIC: str.isalnum,
|
|
INPUT_DIGIT: str.isdigit,
|
|
}
|
|
|
|
(COL_TEXT,
|
|
COL_OBJECT) = list(range(2))
|
|
|
|
class MaskedEntry(UndoableEntry):
|
|
"""
|
|
The MaskedEntry is an Entry subclass with additional features.
|
|
|
|
Additional features:
|
|
|
|
- Mask, force the input to meet certain requirements
|
|
- IconEntry, allows you to have an icon inside the entry
|
|
- convenience functions for completion
|
|
|
|
.. note:: Gramps does not use the mask feature at the moment, so that code
|
|
path is not tested
|
|
"""
|
|
__gtype_name__ = 'MaskedEntry'
|
|
|
|
def __init__(self):
|
|
self._block_changed = False
|
|
UndoableEntry.__init__(self)
|
|
|
|
# connect in UndoableEntry:
|
|
#self.connect('insert-text', self._on_insert_text)
|
|
#self.connect('delete-text', self._on_delete_text)
|
|
self.connect_after('grab-focus', self._after_grab_focus)
|
|
|
|
self.connect('changed', self._on_changed)
|
|
|
|
self.connect('focus', self._on_focus)
|
|
self.connect('focus-out-event', self._on_focus_out_event)
|
|
self.connect('move-cursor', self._on_move_cursor)
|
|
self.connect('button-press-event', self._on_button_press_event)
|
|
self.connect('notify::cursor-position',
|
|
self._on_notify_cursor_position)
|
|
|
|
self._completion = None
|
|
self._exact_completion = False
|
|
self.hasicon = False
|
|
## self._icon = IconEntry(self)
|
|
|
|
# List of validators
|
|
# str -> static characters
|
|
# int -> dynamic, according to constants above
|
|
self._mask_validators = []
|
|
self._mask = None
|
|
# Fields defined by mask
|
|
# each item is a tuble, containing the begining and the end of the
|
|
# field in the text
|
|
self._mask_fields = []
|
|
self._current_field = -1
|
|
self._pos = 0
|
|
self._selecting = False
|
|
|
|
self._block_insert = False
|
|
self._block_delete = False
|
|
self.in_do_draw = False
|
|
|
|
# Virtual methods, note do_size_alloc needs gtk 2.9 +
|
|
## def do_size_allocate(self, allocation):
|
|
## Gtk.Entry.do_size_allocate(self, allocation)
|
|
##
|
|
## if self.get_realized():
|
|
## self._icon.resize_windows()
|
|
|
|
## def do_draw(self, cairo_t):
|
|
## Gtk.Entry.do_draw(self, cairo_t)
|
|
##
|
|
## if Gtk.cairo_should_draw_window(cairo_t, self.get_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)
|
|
|
|
# Mask & Fields
|
|
|
|
def set_mask(self, mask):
|
|
"""
|
|
Set 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:
|
|
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
|
|
"""
|
|
if not mask:
|
|
self.override_font(Pango.FontDescription("sans"))
|
|
self._mask = mask
|
|
return
|
|
|
|
# First, reset
|
|
self._mask_validators = []
|
|
self._mask_fields = []
|
|
self._current_field = -1
|
|
|
|
mask = str(mask)
|
|
input_length = len(mask)
|
|
lenght = 0
|
|
pos = 0
|
|
field_begin = 0
|
|
field_end = 0
|
|
while True:
|
|
if pos >= input_length:
|
|
break
|
|
if mask[pos] in INPUT_FORMATS:
|
|
self._mask_validators += [INPUT_FORMATS[mask[pos]]]
|
|
field_end += 1
|
|
else:
|
|
self._mask_validators.append(mask[pos])
|
|
if field_begin != field_end:
|
|
self._mask_fields.append((field_begin, field_end))
|
|
field_end += 1
|
|
field_begin = field_end
|
|
pos += 1
|
|
|
|
self._mask_fields.append((field_begin, field_end))
|
|
self.override_font(Pango.FontDescription("monospace"))
|
|
|
|
self._really_delete_text(0, -1)
|
|
self._insert_mask(0, input_length)
|
|
self._mask = mask
|
|
|
|
def get_mask(self):
|
|
"""
|
|
:returns: the mask
|
|
"""
|
|
return self._mask
|
|
|
|
def get_field_text(self, field):
|
|
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()
|
|
|
|
def get_fields(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_fields")
|
|
#assert self._mask
|
|
|
|
fields = []
|
|
|
|
text = str(self.get_text())
|
|
for start, end in self._mask_fields:
|
|
fields.append(text[start:end].strip())
|
|
|
|
return fields
|
|
|
|
def get_empty_mask(self, start=None, end=None):
|
|
"""
|
|
Get 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, str):
|
|
s += validator
|
|
else:
|
|
raise AssertionError
|
|
return s
|
|
|
|
def get_field_pos(self, field):
|
|
"""
|
|
Get the position at the specified field.
|
|
"""
|
|
if field >= len(self._mask_fields):
|
|
return None
|
|
|
|
start, end = self._mask_fields[field]
|
|
|
|
return start
|
|
|
|
def _get_field_ideal_pos(self, field):
|
|
start, end = self._mask_fields[field]
|
|
text = self.get_field_text(field)
|
|
pos = start+len(text)
|
|
return pos
|
|
|
|
def get_field(self):
|
|
if self._current_field >= 0:
|
|
return self._current_field
|
|
else:
|
|
return None
|
|
|
|
def set_field(self, field, select=False):
|
|
if field >= len(self._mask_fields):
|
|
return
|
|
|
|
pos = self._get_field_ideal_pos(field)
|
|
self.set_position(pos)
|
|
|
|
if select:
|
|
field_text = self.get_field_text(field)
|
|
start, end = self._mask_fields[field]
|
|
self.select_region(start, pos)
|
|
|
|
self._current_field = field
|
|
|
|
def get_field_length(self, field):
|
|
if 0 <= field < len(self._mask_fields):
|
|
start, end = self._mask_fields[field]
|
|
return end - start
|
|
|
|
def _shift_text(self, start, end, direction=DIRECTION_LEFT,
|
|
positions=1):
|
|
"""
|
|
Shift the text, to the right or left, n positions. Note that this
|
|
does not change the entry text. It returns the shifted text.
|
|
|
|
:param start:
|
|
:param end:
|
|
:param direction: DIRECTION_LEFT or DIRECTION_RIGHT
|
|
:param positions: the number of positions to shift.
|
|
:returns: returns the text between start and end, shifted to the
|
|
direction provided.
|
|
"""
|
|
text = self.get_text()
|
|
new_text = ''
|
|
validators = self._mask_validators
|
|
|
|
if direction == DIRECTION_LEFT:
|
|
i = start
|
|
else:
|
|
i = end - 1
|
|
|
|
# When shifting a text, we wanna keep the static chars where they
|
|
# are, and move the non-static chars to the right position.
|
|
while start <= i < end:
|
|
if isinstance(validators[i], int):
|
|
# Non-static char shoud be here. Get the next one (depending
|
|
# on the direction, and the number of positions to skip.)
|
|
#
|
|
# When shifting left, the next char will be on the right,
|
|
# so, it will be appended, to the new text.
|
|
# Otherwise, when shifting right, the char will be
|
|
# prepended.
|
|
next_pos = self._get_next_non_static_char_pos(i, direction,
|
|
positions-1)
|
|
|
|
# If its outside the bounds of the region, ignore it.
|
|
if not start <= next_pos <= end:
|
|
next_pos = None
|
|
|
|
if next_pos is not None:
|
|
if direction == DIRECTION_LEFT:
|
|
new_text = new_text + text[next_pos]
|
|
else:
|
|
new_text = text[next_pos] + new_text
|
|
else:
|
|
if direction == DIRECTION_LEFT:
|
|
new_text = new_text + ' '
|
|
else:
|
|
new_text = ' ' + new_text
|
|
|
|
else:
|
|
# Keep the static char where it is.
|
|
if direction == DIRECTION_LEFT:
|
|
new_text = new_text + text[i]
|
|
else:
|
|
new_text = text[i] + new_text
|
|
i += direction
|
|
|
|
return new_text
|
|
|
|
def _get_next_non_static_char_pos(self, pos, direction=DIRECTION_LEFT,
|
|
skip=0):
|
|
"""
|
|
Get next non-static char position, skiping some chars, if necessary.
|
|
|
|
:param skip: skip first n chars
|
|
:param direction: direction of the search.
|
|
"""
|
|
text = self.get_text()
|
|
validators = self._mask_validators
|
|
i = pos+direction+skip
|
|
while 0 <= i < len(text):
|
|
if isinstance(validators[i], int):
|
|
return i
|
|
i += direction
|
|
|
|
return None
|
|
|
|
def _get_field_at_pos(self, pos, dir=None):
|
|
"""
|
|
Return the field index at position pos.
|
|
"""
|
|
for p in self._mask_fields:
|
|
if p[0] <= pos <= p[1]:
|
|
return self._mask_fields.index(p)
|
|
|
|
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
|
|
"""
|
|
|
|
self._exact_completion = value
|
|
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, None)
|
|
|
|
def is_empty(self):
|
|
text = self.get_text()
|
|
if self._mask:
|
|
empty = self.get_empty_mask()
|
|
else:
|
|
empty = ''
|
|
|
|
return text == empty
|
|
|
|
# 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 < 0 or position >= len(validators):
|
|
return False
|
|
|
|
validator = validators[position]
|
|
if isinstance(validator, int):
|
|
if not INPUT_CHAR_MAP[validator](text):
|
|
return False
|
|
if isinstance(validator, str):
|
|
if validator == text:
|
|
return True
|
|
return False
|
|
|
|
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(GObject.TYPE_STRING))
|
|
completion.set_text_column(0)
|
|
#completion.connect("match-selected",
|
|
#self._on_completion__match_selected)
|
|
|
|
self._completion = Gtk.Entry.get_completion(self)
|
|
self.set_exact_completion(self._exact_completion)
|
|
return
|
|
|
|
def set_completion_mode(self, popup=None, inline=None):
|
|
"""
|
|
Set the way how completion is presented.
|
|
|
|
:param popup: enable completion in popup window
|
|
:type popup: boolean
|
|
:param inline: enable inline completion
|
|
:type inline: boolean
|
|
"""
|
|
completion = self._get_completion()
|
|
if popup is not None:
|
|
completion.set_popup_completion(popup)
|
|
if inline is not None:
|
|
completion.set_inline_completion(inline)
|
|
|
|
def _completion_exact_match_func(self, completion, key, iter):
|
|
model = completion.get_model()
|
|
if not len(model):
|
|
return
|
|
|
|
content = model[iter][COL_TEXT]
|
|
return content.startswith(self.get_text())
|
|
|
|
def _completion_normal_match_func(self, completion, key, iter, data=None):
|
|
model = completion.get_model()
|
|
if not len(model):
|
|
return
|
|
|
|
content = model[iter][COL_TEXT].lower()
|
|
return key.lower() in content
|
|
|
|
def _on_completion__match_selected(self, completion, model, iter, data=None):
|
|
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()
|
|
|
|
def _appers_later(self, char, start):
|
|
"""
|
|
Check if a char appers later on the mask. If it does, return
|
|
the field it appers at. returns False otherwise.
|
|
"""
|
|
validators = self._mask_validators
|
|
i = start
|
|
while i < len(validators):
|
|
if self._mask_validators[i] == char:
|
|
field = self._get_field_at_pos(i)
|
|
if field is None:
|
|
return False
|
|
|
|
return field
|
|
|
|
i += 1
|
|
|
|
return False
|
|
|
|
def _can_insert_at_pos(self, new, pos):
|
|
"""
|
|
Check if a chararcter can be inserted at some position
|
|
|
|
:param new: The char that wants to be inserted.
|
|
:param pos: The position where it wants to be inserted.
|
|
:returns: Returns None if it can be inserted. If it cannot be,
|
|
return the next position where it can be successfuly
|
|
inserted.
|
|
"""
|
|
validators = self._mask_validators
|
|
|
|
# Do not let insert if the field is full
|
|
field = self._get_field_at_pos(pos)
|
|
if field is not None:
|
|
text = self.get_field_text(field)
|
|
length = self.get_field_length(field)
|
|
if len(text) == length:
|
|
Gdk.beep()
|
|
return pos
|
|
|
|
# If the char confirms to the mask, but is a static char, return the
|
|
# position after that static char.
|
|
if (self._confirms_to_mask(pos, new) and
|
|
not isinstance(validators[pos], int)):
|
|
return pos+1
|
|
|
|
# If does not confirms to mask:
|
|
# - Check if the char the user just tried to enter appers later.
|
|
# - If it does, Jump to the start of the field after that
|
|
if not self._confirms_to_mask(pos, new):
|
|
field = self._appers_later(new, pos)
|
|
if field is not False:
|
|
pos = self.get_field_pos(field+1)
|
|
if pos is not None:
|
|
GLib.idle_add(self.set_position, pos)
|
|
return pos
|
|
|
|
return None
|
|
|
|
# When inserting new text, supose, the entry, at some time is like this,
|
|
# ahd the user presses '0', for instance:
|
|
# --------------------------------
|
|
# | ( 1 2 ) 3 4 5 - 6 7 8 9 |
|
|
# --------------------------------
|
|
# ^ ^ ^
|
|
# S P E
|
|
#
|
|
# S - start of the field (start)
|
|
# E - end of the field (end)
|
|
# P - pos - where the new text is being inserted. (pos)
|
|
#
|
|
# So, the new text will be:
|
|
#
|
|
# the old text, from 0 until P
|
|
# + the new text
|
|
# + the old text, from P until the end of the field, shifted to the
|
|
# right
|
|
# + the old text, from the end of the field, to the end of the text.
|
|
#
|
|
# After inserting, the text will be this:
|
|
# --------------------------------
|
|
# | ( 1 2 ) 3 0 4 5 - 6 7 8 9 |
|
|
# --------------------------------
|
|
# ^ ^ ^
|
|
# S P E
|
|
#
|
|
|
|
def _insert_at_pos(self, text, new, pos):
|
|
"""
|
|
Inserts the character at the give position in text. Note that the
|
|
insertion won't be applied to the entry, but to the text provided.
|
|
|
|
:param text: Text that it will be inserted into.
|
|
:param new: New text to insert.
|
|
:param pos: Positon to insert at
|
|
:returns: Returns a tuple, with the position after the insetion and the
|
|
new text.
|
|
"""
|
|
field = self._get_field_at_pos(pos)
|
|
length = len(new)
|
|
new_pos = pos
|
|
start, end = self._mask_fields[field]
|
|
|
|
# Shift Right
|
|
new_text = (text[:pos] + new +
|
|
self._shift_text(pos, end, DIRECTION_RIGHT)[1:] +
|
|
text[end:])
|
|
|
|
# Overwrite Right
|
|
# new_text = (text[:pos] + new +
|
|
# text[pos+length:end]+
|
|
# text[end:])
|
|
new_pos = pos+1
|
|
GLib.idle_add(self.set_position, new_pos)
|
|
|
|
# If the field is full, jump to the next field
|
|
if len(self.get_field_text(field)) == self.get_field_length(field)-1:
|
|
GLib.idle_add(self.set_field, field+1, True)
|
|
self.set_field(field+1)
|
|
|
|
return new_pos, new_text
|
|
|
|
# Callbacks
|
|
def _on_insert_text(self, editable, new, length, position):
|
|
if self._block_insert:
|
|
return
|
|
if not self._mask:
|
|
UndoableEntry._on_insert_text(self, editable, new, length, position)
|
|
return
|
|
new = str(new)
|
|
pos = self.get_position()
|
|
|
|
self.stop_emission('insert-text')
|
|
|
|
text = self.get_text()
|
|
# Insert one char at a time
|
|
for c in new:
|
|
_pos = self._can_insert_at_pos(c, pos)
|
|
if _pos is None:
|
|
pos, text = self._insert_at_pos(text, c, pos)
|
|
else:
|
|
pos = _pos
|
|
|
|
# Change the text with the new text.
|
|
self._block_changed = True
|
|
self._really_delete_text(0, -1)
|
|
### mask not used in Gramps, following should work though
|
|
##UndoableEntry._on_delete_text(self, editable, 0, -1)
|
|
self._block_changed = False
|
|
|
|
self._really_insert_text(text, 0)
|
|
### mask not used in Gramps, following should work though
|
|
##UndoableEntry._on_insert_text(self, editable, text, len(text),0)
|
|
|
|
# When deleting some text, supose, the entry, at some time is like this:
|
|
# --------------------------------
|
|
# | ( 1 2 ) 3 4 5 6 - 7 8 9 0 |
|
|
# --------------------------------
|
|
# ^ ^ ^ ^
|
|
# S s e E
|
|
#
|
|
# S - start of the field (_start)
|
|
# E - end of the field (_end)
|
|
# s - start of the text being deleted (start)
|
|
# e - end of the text being deleted (end)
|
|
#
|
|
# end - start -> the number of characters being deleted.
|
|
#
|
|
# So, the new text will be:
|
|
#
|
|
# the old text, from 0 until the start of the text being deleted.
|
|
# + the old text, from the start of where the text is being deleted, until
|
|
# the end of the field, shifted to the left, end-start positions
|
|
# + the old text, from the end of the field, to the end of the text.
|
|
#
|
|
# So, after the text is deleted, the entry will look like this:
|
|
#
|
|
# --------------------------------
|
|
# | ( 1 2 ) 3 5 6 - 7 8 9 0 |
|
|
# --------------------------------
|
|
# ^
|
|
# P
|
|
#
|
|
# P = the position of the cursor after the deletion, witch is equal to
|
|
# start (s at the previous ilustration)
|
|
|
|
def _on_delete_text(self, editable, start, end):
|
|
if self._block_delete:
|
|
return
|
|
if not self._mask:
|
|
UndoableEntry._on_delete_text(self, editable, start, end)
|
|
return
|
|
|
|
self.stop_emission('delete-text')
|
|
|
|
pos = self.get_position()
|
|
# Trying to delete an static char. Delete the char before that
|
|
if (0 < start < len(self._mask_validators)
|
|
and not isinstance(self._mask_validators[start], int)
|
|
and pos != start):
|
|
self._on_delete_text(editable, start-1, start)
|
|
### mask not used in Gramps, following should work though
|
|
##UndoableEntry._on_delete_text(self, editable, start-1, start)
|
|
return
|
|
|
|
field = self._get_field_at_pos(end-1)
|
|
# Outside a field. Cannot delete.
|
|
if field is None:
|
|
self.set_position(end-1)
|
|
return
|
|
_start, _end = self._mask_fields[field]
|
|
|
|
# Deleting from outside the bounds of the field.
|
|
if start < _start or end > _end:
|
|
_start, _end = start, end
|
|
|
|
# Change the text
|
|
text = self.get_text()
|
|
|
|
# Shift Left
|
|
new_text = (text[:start] +
|
|
self._shift_text(start, _end, DIRECTION_LEFT,
|
|
end-start) +
|
|
text[_end:])
|
|
|
|
# Overwrite Left
|
|
# empty_mask = self.get_empty_mask()
|
|
# new_text = (text[:_start] +
|
|
# text[_start:start] +
|
|
# empty_mask[start:start+(end-start)] +
|
|
# text[start+(end-start):_end] +
|
|
# text[_end:])
|
|
|
|
new_pos = start
|
|
|
|
self._block_changed = True
|
|
self._really_delete_text(0, -1)
|
|
### mask not used in Gramps, following should work though
|
|
##UndoableEntry._on_delete_text(self, editable, 0, -1)
|
|
self._block_changed = False
|
|
self._really_insert_text(new_text, 0)
|
|
### mask not used in Gramps, following should work though
|
|
##UndoableEntry._on_insert_text(self, editable, text, len(text),0)
|
|
|
|
# Position the cursor on the right place.
|
|
self.set_position(new_pos)
|
|
|
|
if self.is_empty():
|
|
pos = self.get_field_pos(0)
|
|
self.set_position(pos)
|
|
|
|
def _after_grab_focus(self, widget):
|
|
# The text is selectet in grab-focus, so this needs to be done after
|
|
# that:
|
|
if self.is_empty():
|
|
if self._mask:
|
|
self.set_field(0)
|
|
else:
|
|
self.set_position(0)
|
|
|
|
def _on_focus(self, widget, direction):
|
|
if not self._mask:
|
|
return
|
|
|
|
if (direction == Gtk.DIR_TAB_FORWARD or
|
|
direction == Gtk.DIR_DOWN):
|
|
inc = 1
|
|
if (direction == Gtk.DIR_TAB_BACKWARD or
|
|
direction == Gtk.DIR_UP):
|
|
inc = -1
|
|
|
|
field = self._current_field
|
|
|
|
field += inc
|
|
# Leaving the entry
|
|
if field == len(self._mask_fields) or field == -1:
|
|
self.select_region(0, 0)
|
|
self._current_field = -1
|
|
return False
|
|
|
|
if field < 0:
|
|
field = len(self._mask_fields)-1
|
|
|
|
# grab_focus changes the selection, so we need to grab_focus before
|
|
# making the selection.
|
|
self.grab_focus()
|
|
self.set_field(field, select=True)
|
|
|
|
return True
|
|
|
|
def _on_notify_cursor_position(self, widget, pspec):
|
|
if not self._mask:
|
|
return
|
|
|
|
if not self.is_focus():
|
|
return
|
|
|
|
if self._selecting:
|
|
return
|
|
|
|
pos = self.get_position()
|
|
field = self._get_field_at_pos(pos)
|
|
|
|
if pos == 0:
|
|
self.set_position(self.get_field_pos(0))
|
|
return
|
|
|
|
text = self.get_text()
|
|
field = self._get_field_at_pos(pos)
|
|
|
|
# Humm, the pos is not inside any field. Get the next pos inside
|
|
# some field, depending on the direction that the cursor is
|
|
# moving
|
|
diff = pos - self._pos
|
|
_field = field
|
|
while _field is None and (len(text) > pos > 0) and diff:
|
|
pos += diff
|
|
_field = self._get_field_at_pos(pos)
|
|
self._pos = pos
|
|
|
|
if field is None:
|
|
self.set_position(self._pos)
|
|
else:
|
|
self._current_field = field
|
|
self._pos = pos
|
|
|
|
def _on_changed(self, widget):
|
|
if self._block_changed:
|
|
self.stop_emission('changed')
|
|
|
|
def _on_focus_out_event(self, widget, event):
|
|
if not self._mask:
|
|
return
|
|
|
|
self._current_field = -1
|
|
|
|
def _on_move_cursor(self, entry, step, count, extend_selection):
|
|
self._selecting = extend_selection
|
|
|
|
def _on_button_press_event(self, entry, event ):
|
|
if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 1:
|
|
self._selecting = True
|
|
elif event.type == Gdk.EventType.BUTTON_RELEASE and event.button == 1:
|
|
self._selecting = True
|
|
|
|
# IconEntry
|
|
|
|
def set_tooltip(self, text):
|
|
self.set_icon_tooltip_text(Gtk.EntryIconPosition.SECONDARY, text)
|
|
|
|
def set_pixbuf(self, pixbuf):
|
|
self.set_icon_from_pixbuf(Gtk.EntryIconPosition.SECONDARY, pixbuf)
|
|
|
|
def set_stock(self, icon_name):
|
|
self.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, icon_name)
|
|
|
|
# Gtk.EntryCompletion convenience function
|
|
|
|
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, ))
|
|
|
|
#number = (int, float, long)
|
|
|
|
VALIDATION_ICON_WIDTH = 16
|
|
MANDATORY_ICON = 'dialog-information'
|
|
ERROR_ICON = 'process-stop'
|
|
FADE_TIME = 2500
|
|
|
|
class ValidatableMaskedEntry(MaskedEntry):
|
|
"""
|
|
It extends the MaskedEntry with validation feature.
|
|
|
|
Merged from Kiwi's ValidatableProxyWidgetMixin and ProxyEntry.
|
|
To provide custom validation connect to the 'validate' signal
|
|
of the instance.
|
|
"""
|
|
|
|
__gtype_name__ = 'ValidatableMaskedEntry'
|
|
|
|
__gsignals__ = {
|
|
'content-changed': (GObject.SignalFlags.RUN_FIRST,
|
|
None,
|
|
()),
|
|
'validation-changed': (GObject.SignalFlags.RUN_FIRST,
|
|
None,
|
|
(GObject.TYPE_BOOLEAN, )),
|
|
'validate': (GObject.SignalFlags.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, err_color="pink", error_icon=ERROR_ICON):
|
|
self.data_type = None
|
|
self.mandatory = False
|
|
self.error_icon = error_icon
|
|
self._block_changed = False
|
|
|
|
MaskedEntry.__init__(self)
|
|
|
|
self._valid = True
|
|
self._def_error_msg = None
|
|
|
|
self.__fade_tag = None
|
|
provider = Gtk.CssProvider()
|
|
css = '.fade {\n'
|
|
css += ' background: {};\n'.format(err_color)
|
|
css += ' transition: background {:d}ms linear;\n'.format(FADE_TIME)
|
|
css += '}'
|
|
css += '.bg {\n'
|
|
css += ' background: {};\n'.format(err_color)
|
|
css += '}'
|
|
provider.load_from_data(css.encode('utf8'))
|
|
context = self.get_style_context()
|
|
context.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
|
|
|
# FIXME put data type support back
|
|
#self.set_property('data-type', data_type)
|
|
|
|
# Virtual methods
|
|
def do_changed(self):
|
|
block_changed = getattr(self, '_block_changed', True)
|
|
if block_changed:
|
|
self.stop_emission_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 set_default_error_msg(self, text):
|
|
"""
|
|
Set default message for validation error.
|
|
|
|
Default error message for an instance is useful when completion is
|
|
used, because this case custom validation is not called.
|
|
|
|
:param text: can contain one and only one '%s', where the actual value
|
|
of the Entry will be inserted.
|
|
:type text: str
|
|
"""
|
|
if not isinstance(text, str):
|
|
raise TypeError("text must be a string")
|
|
|
|
self._def_error_msg = text
|
|
|
|
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 custom validation method
|
|
# of the instance and gets the exception (if any).
|
|
error = self.emit("validate", text)
|
|
if error:
|
|
raise error
|
|
|
|
self.set_valid()
|
|
return text
|
|
except ValidationError as e:
|
|
self.set_invalid(str(e))
|
|
return None
|
|
|
|
def set_valid(self):
|
|
"""
|
|
Change 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)
|
|
if self.is_valid():
|
|
return
|
|
|
|
self._set_valid_state(True)
|
|
|
|
if self.__fade_tag is not None:
|
|
GLib.source_remove(self.__fade_tag)
|
|
self.__fade_tag = None
|
|
self.set_stock(None)
|
|
context = self.get_style_context()
|
|
context.remove_class('fade')
|
|
context.remove_class('bg')
|
|
|
|
def set_invalid(self, text=None, fade=True):
|
|
"""
|
|
Change 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)
|
|
if not self.is_valid():
|
|
return
|
|
|
|
self._set_valid_state(False)
|
|
|
|
generic_text = _("'%s' is not a valid value "
|
|
"for this field") % self.get_text()
|
|
|
|
# If there is no error text, let's try with the default or
|
|
# fall back to a generic one
|
|
if not text:
|
|
text = self._def_error_msg
|
|
if not text:
|
|
text = generic_text
|
|
|
|
try:
|
|
text.index('%s')
|
|
text = text % self.get_text()
|
|
except TypeError:
|
|
# if text contains '%s' more than once
|
|
_LOG.error('There must be only one instance of "%s"'
|
|
' in validation error message')
|
|
# fall back to a generic one so the error icon still have a tooltip
|
|
text = generic_text
|
|
except ValueError:
|
|
# if text does not contain '%s'
|
|
pass
|
|
|
|
self.set_tooltip(text)
|
|
|
|
context = self.get_style_context()
|
|
if fade:
|
|
self.__fade_tag = GLib.timeout_add(FADE_TIME, self.__fade_finished)
|
|
context.add_class('fade')
|
|
else:
|
|
self.set_stock(self.error_icon)
|
|
context.add_class('bg')
|
|
|
|
def set_blank(self):
|
|
"""
|
|
Change 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.set_tooltip(_('This field is mandatory'))
|
|
valid = False
|
|
else:
|
|
valid = True
|
|
|
|
self._set_valid_state(valid)
|
|
|
|
def set_text(self, text):
|
|
"""
|
|
Set 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
|
|
|
|
def __fade_finished(self):
|
|
"""Set error icon after fade has finished."""
|
|
self.__fade_tag = None
|
|
self.set_stock(self.error_icon)
|
|
return False
|
|
|
|
|
|
def main(args):
|
|
from gramps.gen.datehandler import parser
|
|
|
|
def on_validate(widget, text):
|
|
myDate = parser.parse(text)
|
|
if not myDate.is_regular():
|
|
# used on AgeOnDateGramplet
|
|
return ValidationError(_("'%s' is not a valid date value"))
|
|
|
|
win = Gtk.Window()
|
|
win.set_title('ValidatableMaskedEntry test window')
|
|
win.set_position(Gtk.WindowPosition.CENTER)
|
|
def cb(window, event):
|
|
Gtk.main_quit()
|
|
win.connect('delete-event', cb)
|
|
|
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
win.add(vbox)
|
|
|
|
label = Gtk.Label(label='Pre-filled entry validated against the given list:')
|
|
vbox.pack_start(label, True, True, 0)
|
|
|
|
widget1 = ValidatableMaskedEntry(str)
|
|
widget1.set_completion_mode(inline=True, popup=False)
|
|
widget1.set_default_error_msg("'%s' is not a default Event")
|
|
#widget1.set_default_error_msg(widget1)
|
|
widget1.prefill(('Birth', 'Death', 'Conseption'))
|
|
#widget1.set_exact_completion(True)
|
|
vbox.pack_start(widget1, True, False, 0)
|
|
|
|
label = Gtk.Label(label='Mandatory masked entry validated against user function:')
|
|
vbox.pack_start(label, True, True, 0)
|
|
|
|
#widget2 = ValidatableMaskedEntry(str, "#e0e0e0", error_icon=None)
|
|
widget2 = ValidatableMaskedEntry()
|
|
widget2.set_mask('00/00/0000')
|
|
widget2.connect('validate', on_validate)
|
|
widget2.mandatory = True
|
|
vbox.pack_start(widget2, True, False, 0)
|
|
|
|
win.show_all()
|
|
Gtk.main()
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
# fall back to root logger for testing
|
|
_LOG = logging
|
|
sys.exit(main(sys.argv))
|