# Copyright (c) 2011-2014, B.I.Stepanov Institute of Physics, National Academy
# of Sciences of Belarus.
#
# 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.

"""Editing widgets with built-in error and constraint checking."""

from PyQt4.QtCore import *
from PyQt4.QtGui import *

from common.utils import gui
from common.utils import txt

__all__ = ['NumberEditWidget', 'StringEditWidget', 'ComboBoxEditWidget',
    'BoolEditWidget']

# *****************************************************************************
class EditWidget(QWidget):
    """Base class defining interface and layout for an edit widget capable of
    displaying an error message and having a built-in mechanism for constraint
    checking.

    Signals:
      - 'valueChanged': the value being edited has been modified or became
        unavailable due to syntax or constraint checking errors.
      - 'returnPressed': Return or Enter key has been pressed by the user.

    Use 'setValue' to manually set the value to be displayed in the widget and
    'getValue' to retrieve the modified value edited by the user, unless some
    constraints are not met or a custom error message has been set on the
    widget.

    Redefine 'updateEditWidget' method to provide the functionlality to pass a
    value from the outside world to the actual edit widget. Use 'updateValue'
    for the inverse operation, calling this method from the editing slots to be
    connected in 'createEditWidget' whenever the value has been interactively
    modified by the user. Redefine 'getErrorMessage' to provide the
    functionality for constraint checking.

    You don't have to manually call the 'valueChanged' signal from any of the
    methods to be redefined. However, responsibility for implementation of the
    'returnPressed' signal lies completely on the derived class."""

    # ---- Signals ------------------------------------------------------------
    valueChanged = pyqtSignal()
    returnPressed = pyqtSignal()

    # ---- Public methods -----------------------------------------------------
    def __init__(self, parent = None):

        QWidget.__init__(self, parent)

        # ---- Members ----
        # Actual type of the 'value' will be determined in the derived class.
        self.value = None
        # 'True' if the current error was set with 'setCustomErrorMessage'.
        self.hasCustomErrorMessage = False

        # ---- Layout ----
        layout = QHBoxLayout()

        # The layout consists of the actual edit widget and a hideable warning
        # icon, whose tooltip will hold the error message, if any.
        self.edit = self.createEditWidget()
        layout.addWidget(self.edit)

        errorIcon = gui.loadIcon('warning')
        errorIconHeight = QFontMetrics(self.edit.font()).height()
        self.errorLabel = QLabel()
        self.errorLabel.setPixmap(errorIcon.pixmap(errorIconHeight))
        layout.addWidget(self.errorLabel)

        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

    def getEditWidget(self):
        """Return the widget that actually edits the value."""

        return self.edit

    def hasValue(self):
        """Check if the value being currently displayed by the widget is valid
        and meets any of the required constraints."""

        return not self.errorLabel.isVisible()

    def getValue(self):
        """Return the current widget value converted to its genuine data type
        (e.g. 'int' for an integer editor) or 'None' if the current value is
        invalid or does not satisfy the constraints."""

        if self.hasValue():
            return self.value
        else:
            return None

    def setValue(self, value):
        """Manually set the value to be displayed by the widget.

        If the value is 'None' or does not meet the constraints, still modify
        the edit widget in a suitable way, but turn the error status on (so
        that an error icon is displayed and 'hasValue' returns 'False')."""

        self.updateEditWidget(value)
        self.updateValue(value)

    def setCustomErrorMessage(self, message):
        """Assign to the widget an error message that is not related to any of
        the built-in constraint checking failures.

        Custom error messages have greater priority than the ordinary ones. Use
        'clearCustomErrorMessage' to return to normal constraint checking."""

        self.errorLabel.setVisible(True)
        self.errorLabel.setToolTip(message)
        # Make sure that the message won't get overwritten until it is cleared.
        self.hasCustomErrorMessage = True

    def clearCustomErrorMessage(self):
        """Clear the error message set by 'setCustomErrorMessage', if any."""

        self.hasCustomErrorMessage = False
        self.updateErrorMessage()

    # ---- Protected overridable methods --------------------------------------
    def createEditWidget(self):
        """Create a widget capable of actually editing a value of the required
        type and connect the required slots to it, so that interactive
        modifications of the widget's content, as well as Enter key presses,
        could be tracked.

        Return the created edit widget."""

        return QWidget()

    def updateEditWidget(self, value):
        """Modify the widget content displayed to the user so that it matches
        the given 'value' of the genuine data type associated with the widget
        (e.g. 'int' for an integer editor)."""

        pass

    def getErrorMessage(self, value):
        """Check if the given 'value' (of the genuine widget data type)
        violates any of the constraints defined by the widget and return an
        appropriate error string if it does. Otherwise, return 'None'."""

        return None

    # ---- Protected methods --------------------------------------------------
    def updateValue(self, value):
        """Assign a value (of the genuine widget data type) to the widget and
        check it against the constraints (by means of a 'getErrorMessage'
        call), but don't modify the widget content displayed to the user.

        This function has to be called in response to interactive modifications
        of the value being edited."""

        self.value = value
        self.updateErrorMessage()
        self.valueChanged.emit()

    # ---- Private methods ----------------------------------------------------
    def updateErrorMessage(self):
        """Show the error label if 'self.value' does not satisfy some of the
        constraints, and hide it otherwise."""

        # Custom error messages have a greater priority than the ordinary ones.
        if self.hasCustomErrorMessage:
            return

        errorMessage = self.getErrorMessage(self.value)

        if errorMessage is not None:
            self.errorLabel.setVisible(True)
            self.errorLabel.setToolTip(errorMessage)
        else:
            self.errorLabel.setVisible(False)
            self.errorLabel.setToolTip('')

# *****************************************************************************
class NumberEditWidget(EditWidget):
    """Floating point or integer value editor with built-in error and
    constraint checking.

    Attributes:
      - 'minValue', 'maxValue': constraints defining lower and upper boundaries
        for the number being edited.
      - 'isMinStrict', 'isMaxStrict': 'True' if the respective boundary values
        themselves are not acceptable.
      - 'isFloatingPoint': 'True' if the value being edited is a floating point
        and 'False' if it is an integer."""

    # ---- Public methods -----------------------------------------------------
    def __init__(self, minValue = None, maxValue = None, isMinStrict = False,
        isMaxStrict = False, isFloatingPoint = True, parent = None):

        EditWidget.__init__(self, parent)

        # ---- Members ----
        if isFloatingPoint:
            assert minValue is None or isinstance(minValue, float)
            assert maxValue is None or isinstance(maxValue, float)
        else:
            assert minValue is None or isinstance(minValue, int)
            assert maxValue is None or isinstance(maxValue, int)

        self.minValue = minValue
        self.maxValue = maxValue
        self.isMinStrict = isMinStrict
        self.isMaxStrict = isMaxStrict
        self.isFloatingPoint = isFloatingPoint

    # ---- Private overridden methods -----------------------------------------
    def createEditWidget(self):

        edit = QLineEdit()
        edit.textEdited.connect(self.onTextEdited)
        edit.returnPressed.connect(self.onReturnPressed)

        return edit

    def updateEditWidget(self, value):

        if value is None:
            self.edit.setText('')
        else:
            # This will work regardless of the 'value' data type. Actual type
            # checking will be performed in 'getErrorMessage'.
            self.edit.setText(str(value))

    def getErrorMessage(self, value):

        # 'None' value is used by 'onTextEdited' to denote invalid strings.
        if value is None:
            if self.isFloatingPoint:
                return ('The value entered is not a valid floating point '
                    'number')
            else:
                return 'The value entered is not an integer'

        # Make sure that 'value' has the valid data type.
        if self.isFloatingPoint:
            assert isinstance(value, float)
        else:
            assert isinstance(value, int)

        # Check for constraint violations.
        if (self.minValue is not None and self.isMinStrict and
            value <= self.minValue):
            return 'This value must be greater than %g' % self.minValue
        if self.minValue is not None and value < self.minValue:
            return ('This value must be greater than or equal to %g' %
                self.minValue)

        if (self.maxValue is not None and self.isMaxStrict and
            value >= self.maxValue):
            return 'This value must be less than %g' % self.maxValue
        if self.maxValue is not None and value > self.maxValue:
            return ('This value must be less than or equal to %g' %
                self.maxValue)

        return None

    # ---- Private slots ------------------------------------------------------
    def onTextEdited(self, text):

        # Set the value to 'None' if the text is not a valid number.
        if self.isFloatingPoint:
            value = txt.stringToFloat(text)
        else:
            value = txt.stringToInt(text)

        self.updateValue(value)

    def onReturnPressed(self):
        self.returnPressed.emit()

# *****************************************************************************
class StringEditWidget(EditWidget):
    """ASCII string editor with built-in error and constraint checking.

    Attributes:
      - 'minLength', 'maxLength': minimal and maximal character counts for the
        string being edited or 'None' if string length is not restricted."""

    # ---- Public methods -----------------------------------------------------
    def __init__(self, minLength = None, maxLength = None, parent = None):

        EditWidget.__init__(self, parent)

        # ---- Members ----
        assert minLength is None or minLength > 0
        assert maxLength is None or maxLength > 0
        assert minLength is None or maxLength is None or minLength <= maxLength

        self.minLength = minLength
        self.maxLength = maxLength

    # ---- Private overridden methods -----------------------------------------
    def createEditWidget(self):

        edit = QLineEdit()
        edit.textEdited.connect(self.onTextEdited)
        edit.returnPressed.connect(self.onReturnPressed)

        return edit

    def updateEditWidget(self, value):

        if value is None:
            self.edit.setText('')
        else:
            # This widget is meant for editing of ASCII strings only.
            assert isinstance(value, str)
            self.edit.setText(value)

    def getErrorMessage(self, value):

        # 'None' value is used by 'onTextEdited' to denote invalid strings.
        if value is None:
            return 'The value entered is not a valid ASCII string'

        # Make sure that 'value' has the valid data type.
        assert isinstance(value, str)

        # Check for constraint violations.
        if self.minLength is not None and len(value) < self.minLength:
            if self.minLength == 1:
                return 'This value must be a non-empty string'
            else:
                return ('This string may contain at least %d characters' %
                    self.minLength)

        if self.maxLength is not None and len(value) > self.maxLength:
            return ('This string may contain at most %d characters' %
                self.maxLength)

        return None

    # ---- Private slots ------------------------------------------------------
    def onTextEdited(self, text):

        # Set the value to 'None' if the text is not a valid ASCII string.
        try:
            value = str(text)
        except ValueError:
            value = None

        self.updateValue(value)

    def onReturnPressed(self):
        self.returnPressed.emit()

# *****************************************************************************
class ReturnSignallingComboBox(QComboBox):
    """A 'QComboBox' capable of detecting Enter key presses (passing these
    events to the outer world by means of the 'returnPressed' signal)."""

    # ---- Signals ------------------------------------------------------------
    returnPressed = pyqtSignal()

    # ---- Private overridden methods -----------------------------------------
    def keyPressEvent(self, event):
        # Perform the action first.
        QComboBox.keyPressEvent(self, event)

        # If that was a Return or Enter key, inform the outer world about that.
        if event.key() in (Qt.Key_Return, Qt.Key_Enter):
            self.returnPressed.emit()

# *****************************************************************************
class ComboBoxEditWidget(EditWidget):
    """An 'EditWidget' capable of selecting an item from the given fixed list
    of strings.

    Attributes:
      - 'admissibleValues': list of strings to select the value from."""

    # ---- Public methods -----------------------------------------------------
    def __init__(self, admissibleValues, parent = None):

        EditWidget.__init__(self, parent = None)

        # ---- Members ----
        self.admissibleValues = admissibleValues

        # ---- Initialization ----
        for value in self.admissibleValues:
            assert isinstance(value, str) or isinstance(value, unicode)
            self.edit.addItem(value)

    # ---- Private overridden methods -----------------------------------------
    def createEditWidget(self):

        edit = ReturnSignallingComboBox()

        edit.currentIndexChanged.connect(self.onIndexChanged)
        edit.returnPressed.connect(self.returnPressed)

        return edit

    def updateEditWidget(self, value):

        assert value in self.admissibleValues
        self.edit.setCurrentIndex(self.edit.findText(value))

    # ---- Private slots ------------------------------------------------------
    @pyqtSlot('int')
    def onIndexChanged(self, index):

        value = self.admissibleValues[index]
        assert value == self.edit.currentText()  # Just to feel safe.
        self.updateValue(value)

# *****************************************************************************
class ReturnSignallingCheckBox(QCheckBox):
    """A 'QCheckBox' capable of detecting Enter key presses (passing these
    events to the outer world by means of the 'returnPressed' signal)."""

    # ---- Signals ------------------------------------------------------------
    returnPressed = pyqtSignal()

    # ---- Private overridden methods -----------------------------------------
    def keyPressEvent(self, event):
        # Perform the action first.
        QCheckBox.keyPressEvent(self, event)

        # If that was a Return or Enter key, inform the outer world about that.
        if event.key() in (Qt.Key_Return, Qt.Key_Enter):
            self.returnPressed.emit()

# *****************************************************************************
class BoolEditWidget(EditWidget):
    """An 'EditWidget' for the 'bool' data type."""

    # ---- Public methods -----------------------------------------------------
    def __init__(self, label, parent = None):
        """Parameters:

          - 'label': text to be displayed along the checkbox implementing the
            edit."""

        EditWidget.__init__(self, parent = None)

        # ---- Initialization ----
        self.edit.setText(label)

    # ---- Private overridden methods -----------------------------------------
    def createEditWidget(self):

        edit = ReturnSignallingCheckBox()

        edit.stateChanged.connect(self.onStateChanged)
        edit.returnPressed.connect(self.returnPressed)

        return edit

    def updateEditWidget(self, value):

        self.edit.setCheckState(Qt.Checked if value else Qt.Unchecked)

    # ---- Private slots ------------------------------------------------------
    @pyqtSlot('int')
    def onStateChanged(self, state):

        if state == Qt.Unchecked:
            value = False
        else:
            assert state == Qt.Checked
            value = True

        self.updateValue(value)
