MaskTextFilter.java

// Copyright (C) 2017 Benoît Moreau (ben.12)
// 
// This software may be modified and distributed under the terms
// of the MIT license.  See the LICENSE file for details.
package com.ben12.infxnity.control.text;

import java.util.Arrays;
import java.util.Optional;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import javafx.beans.NamedArg;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.scene.control.Control;
import javafx.scene.control.TextFormatter.Change;
import javafx.scene.control.TextInputControl;

/**
 * <p>
 * Mask to use as filter for {@link javafx.scene.control.TextFormatter TextFormatter}.
 * </p>
 * Example for create a {@link javafx.scene.control.TextField TextField} allowing only french phone numbers:
 * <pre>
 * {@code
 * 	final TextField textField = new TextField();
 * 	final MaskCharacter[] mask = MaskBuilder.newBuilder()
 * 			.appendLiteral("+33 ")
 * 			.appendDigit('6')
 * 			.appendLiteral(" ")
 * 			.appendDigit(2)
 * 			.appendLiteral(" ")
 * 			.appendDigit(2)
 * 			.appendLiteral(" ")
 * 			.appendDigit(2)
 * 			.appendLiteral(" ")
 * 			.appendDigit(2)
 * 			.build();
 * 	textField.setTextFormatter(new TextFormatter<>(new MaskTextFilter(textField, false, mask)));
 * }
 * </pre>
 * Default text will be "+33 6 00 00 00 00".<br>
 * Caret will be placed in 4th position : "+33 |6 00 00 00 00".<br>
 * An navigation to the right will do that:<br>
 * "+33 6 |00 00 00 00"<br>
 * "+33 6 0|0 00 00 00"<br>
 * "+33 6 00 |00 00 00"<br>
 * "+33 6 00 0|0 00 00"<br>
 * "+33 6 00 00 |00 00"<br>
 * "+33 6 00 00 0|0 00"<br>
 * "+33 6 00 00 00 |00"<br>
 * "+33 6 00 00 00 0|0"<br>
 * "+33 6 00 00 00 00|"<br>
 * 
 * @author Benoît Moreau (ben.12)
 * @see MaskBuilder
 */
public class MaskTextFilter implements UnaryOperator<Change>
{
    private final MaskCharacter[] mask;

    private Control               settingDefaultOn = null;

    /**
     * @param pMask
     *            the mask to use
     */
    public MaskTextFilter(@NamedArg("mask") final MaskCharacter... pMask)
    {
        mask = Arrays.copyOf(pMask, pMask.length);
    }

    /**
     * @param input
     *            {@link TextInputControl} where the filter will be applied
     * @param setDefaultOnFocus
     *            true to set the default value when it gain the focus, otherwise the default value is immediately applied
     * @param pMask
     *            the mask to use
     */
    public MaskTextFilter(final TextInputControl input, final boolean setDefaultOnFocus, final MaskCharacter... pMask)
    {
        mask = Arrays.copyOf(pMask, pMask.length);

        install(input, setDefaultOnFocus);
    }

    /**
     * @param input
     *            {@link TextInputControl} where set the default value
     * @param setDefaultOnFocus
     *            true to set the default value when it gain the focus, otherwise the default value is immediately applied
     */
    public void install(final TextInputControl input, final boolean setDefaultOnFocus)
    {
        if (input != null)
        {
            if (!setDefaultOnFocus)
            {
                applyDefault(input);
            }
            else
            {
                final ChangeListener<Boolean> focusListener = new ChangeListener<Boolean>()
                {
                    @Override
                    public void changed(final ObservableValue<? extends Boolean> observable, final Boolean oldValue,
                            final Boolean newValue)
                    {
                        if ((observable == input.focusedProperty() && newValue && !input.isPressed())
                                || (observable == input.pressedProperty() && !newValue && input.isFocused()))
                        {
                            applyDefault(input);
                            input.focusedProperty().removeListener(this);
                            input.pressedProperty().removeListener(this);
                        }
                    }
                };

                input.focusedProperty().addListener(focusListener);
                input.pressedProperty().addListener(focusListener);
            }
        }
    }

    /**
     * @param input
     *            {@link TextInputControl} where set the default value
     */
    public void applyDefault(final TextInputControl input)
    {
        try
        {
            settingDefaultOn = input;
            final String defaultText = Stream.of(mask) //
                                             .map(m -> Character.toString(m.getDefault()))
                                             .collect(Collectors.joining());
            input.setText(defaultText);

            final int firstAllowedPosition = IntStream.range(0, mask.length)
                                                      .filter(i -> mask[i].isNavigable())
                                                      .findFirst()
                                                      .orElse(0);
            input.selectRange(firstAllowedPosition, firstAllowedPosition);
        }
        finally
        {
            settingDefaultOn = null;
        }
    }

    @Override
    public Change apply(final Change c)
    {
        if (settingDefaultOn == c.getControl())
        {
            return c;
        }

        if (c.isContentChange() && !correctContentChange(c))
        {
            return null;
        }

        adjustCaretPosition(c);

        return c;
    }

    private boolean correctContentChange(final Change c)
    {
        Optional<String> correctNewText = Optional.empty();

        if (c.isReplaced())
        {
            correctNewText = correctReplacedText(c);
        }
        else if (c.isAdded())
        {
            correctNewText = correctAddedText(c);
        }
        else if (c.isDeleted())
        {
            correctNewText = correctDeletedText(c);
        }

        if (correctNewText.isPresent())
        {
            final int start = c.getRangeStart();
            c.setRange(start, Math.min(start + correctNewText.get().length(), c.getControlText().length()));
            c.setText(correctNewText.get());
        }

        return correctNewText.isPresent();
    }

    private Optional<String> correctReplacedText(final Change c)
    {
        final int start = c.getRangeStart();
        final int end = c.getRangeEnd();
        final String text = c.getText();

        final StringBuilder newText = new StringBuilder(end - start);
        for (int i = start; i - start < text.length() && i < end && i < mask.length; i++)
        {
            final char ch = text.charAt(i - start);
            if (mask[i].isAllowed(ch))
            {
                newText.append(mask[i].tranform(ch));
            }
            else
            {
                return Optional.empty();
            }
        }

        for (int i = start + text.length(); i < end && i < mask.length; i++)
        {
            newText.append(mask[i].getDefault());
        }

        return Optional.of(newText.toString());
    }

    private Optional<String> correctAddedText(final Change c)
    {
        final int start = c.getRangeStart();
        final String text = c.getText();

        final StringBuilder newText = new StringBuilder(text.length());

        for (int i = start; i - start < text.length() && i < mask.length; i++)
        {
            final char ch = text.charAt(i - start);
            if (mask[i].isAllowed(ch))
            {
                newText.append(mask[i].tranform(ch));
            }
            else
            {
                return Optional.empty();
            }
        }

        return Optional.of(newText.toString());
    }

    private Optional<String> correctDeletedText(final Change c)
    {
        int start = c.getRangeStart();
        final int end = c.getRangeEnd();

        final StringBuilder newText = new StringBuilder(end - start);

        for (int i = start; i < end; i++)
        {
            newText.append(mask[i].getDefault());
        }

        // For backspace case
        for (int i = start; i > 0 && !mask[i].isNavigable(); i--, start--)
        {
            newText.insert(0, mask[i - 1].getDefault());
        }

        c.setRange(start, end);

        return Optional.of(newText.toString());
    }

    private void adjustCaretPosition(final Change c)
    {
        final int oldPosition = c.getControlCaretPosition();
        int position = Math.min(c.getCaretPosition(), mask.length);
        if (oldPosition != position)
        {
            final int sign = (position > oldPosition ? 1 : -1);
            while (position > 0 && position < mask.length && !mask[position].isNavigable())
            {
                position += sign;
            }
            while (position < mask.length && !mask[position].isNavigable())
            {
                position++;
            }
        }

        position = Math.min(position, c.getControlNewText().length());

        if (c.getAnchor() == c.getCaretPosition())
        {
            c.setAnchor(position);
        }
        c.setCaretPosition(position);
    }

    /**
     * Mask character interface.
     * 
     * @author Benoît Moreau (ben.12)
     */
    public interface MaskCharacter
    {
        /**
         * @param c
         *            an input character
         * @return true if the character is allowed, false otherwise
         */
        boolean isAllowed(char c);

        /**
         * @param c
         *            an input character
         * @return the transformed character to set
         */
        char tranform(char c);

        /**
         * @return the default character
         */
        char getDefault();

        /**
         * @return true if caret can be placed before the character, false otherwise
         */
        boolean isNavigable();
    }
}