Add MaskTextField
This commit is contained in:
parent
c40ec4e3b3
commit
d521f8ac4a
@ -5,6 +5,7 @@
|
|||||||
### Features
|
### Features
|
||||||
|
|
||||||
- (Base) New `DeckPane` component with swipe and slide transition support.
|
- (Base) New `DeckPane` component with swipe and slide transition support.
|
||||||
|
- (Base) New `MaskTextField` (and `MaskTextFormatter`) component to support masked text input.
|
||||||
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
|
- (CSS) 🚀 New MacOS-like Cupertino theme in light and dark variants.
|
||||||
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
|
- (CSS) 🚀 New [Dracula](https://ui.draculatheme.com/) theme.
|
||||||
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).
|
- (CSS) New `TabPane` style. There are three styles supported: default, floating and classic (new one).
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: MIT
|
||||||
|
*
|
||||||
|
* Copyright (c) 2013, 2016, Oracle and/or its affiliates. All rights reserved.
|
||||||
|
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
|
||||||
|
*
|
||||||
|
* This code is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License version 2 only, as
|
||||||
|
* published by the Free Software Foundation. Oracle designates this
|
||||||
|
* particular file as subject to the "Classpath" exception as provided
|
||||||
|
* by Oracle in the LICENSE file that accompanied this code.
|
||||||
|
*
|
||||||
|
* This code 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
|
||||||
|
* version 2 for more details (a copy is included in the LICENSE file that
|
||||||
|
* accompanied this code).
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License version
|
||||||
|
* 2 along with this work; if not, write to the Free Software Foundation,
|
||||||
|
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
*
|
||||||
|
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
|
||||||
|
* or visit www.oracle.com if you need additional information or have any
|
||||||
|
* questions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package atlantafx.base.controls;
|
||||||
|
|
||||||
|
import atlantafx.base.util.MaskChar;
|
||||||
|
import atlantafx.base.util.MaskTextFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import javafx.beans.NamedArg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a convenience wrapper for instantiating a {@link CustomTextField}
|
||||||
|
* with {@code MaskTextFormatter}. For additional info refer to the {@link MaskTextFormatter}
|
||||||
|
* docs.
|
||||||
|
*/
|
||||||
|
public class MaskTextField extends CustomTextField {
|
||||||
|
|
||||||
|
protected final MaskTextFormatter formatter;
|
||||||
|
|
||||||
|
public MaskTextField(@NamedArg("text") String mask) {
|
||||||
|
this("", mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaskTextField(@NamedArg("text") String text, @NamedArg("mask") String mask) {
|
||||||
|
super(text);
|
||||||
|
this.formatter = MaskTextFormatter.create(this, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaskTextField(String text, List<MaskChar> mask) {
|
||||||
|
super(text);
|
||||||
|
this.formatter = MaskTextFormatter.create(this, mask);
|
||||||
|
}
|
||||||
|
}
|
47
base/src/main/java/atlantafx/base/util/MaskChar.java
Normal file
47
base/src/main/java/atlantafx/base/util/MaskChar.java
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
/* SPDX-License-Identifier: MIT */
|
||||||
|
|
||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides basic API for defining an input mask.
|
||||||
|
*/
|
||||||
|
public interface MaskChar {
|
||||||
|
|
||||||
|
char UNDERSCORE = '_';
|
||||||
|
|
||||||
|
char INPUT_MASK_LETTER = 'A';
|
||||||
|
char INPUT_MASK_DIGIT_OR_LETTER = 'N';
|
||||||
|
char INPUT_MASK_ANY_NON_SPACE = 'X';
|
||||||
|
char INPUT_MASK_HEX = 'H';
|
||||||
|
char INPUT_MASK_DIGIT_NON_ZERO = 'D';
|
||||||
|
char INPUT_MASK_DIGIT = '9';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_8 = '8';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_7 = '7';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_6 = '6';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_5 = '5';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_4 = '4';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_3 = '3';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_2 = '2';
|
||||||
|
char INPUT_MASK_DIGIT_0_TO_1 = '1';
|
||||||
|
char INPUT_MASK_DIGIT_ZERO = '0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the character is allowed, false otherwise.
|
||||||
|
*/
|
||||||
|
boolean isAllowed(char ch);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms user input character before setting.
|
||||||
|
*/
|
||||||
|
char transform(char ch);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the placeholder for the mask character.
|
||||||
|
*/
|
||||||
|
char getPlaceholder();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether character is fixed (prefix, suffix or separator).
|
||||||
|
*/
|
||||||
|
boolean isFixed();
|
||||||
|
}
|
385
base/src/main/java/atlantafx/base/util/MaskTextFormatter.java
Normal file
385
base/src/main/java/atlantafx/base/util/MaskTextFormatter.java
Normal file
@ -0,0 +1,385 @@
|
|||||||
|
/* SPDX-License-Identifier: MIT */
|
||||||
|
|
||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import javafx.application.Platform;
|
||||||
|
import javafx.scene.control.TextField;
|
||||||
|
import javafx.scene.control.TextFormatter;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@code TextFormatter} that can restrict the user input by applying a position-based mask.
|
||||||
|
* It works for the editing cases when the input string has a fixed length and each character
|
||||||
|
* can be restricted based on its position.<br/><br/>
|
||||||
|
*
|
||||||
|
* <p><h3>Input Mask</h3>
|
||||||
|
* You can specify an input mask either as a string of the predefined characters or as a list of
|
||||||
|
* {@link MaskChar}, including your own implementation if you're not satisfied with the default
|
||||||
|
* {@link SimpleMaskChar}, e.g. if you want to override the placeholder character.
|
||||||
|
* The pre-defined mask characters are:
|
||||||
|
* <ul>
|
||||||
|
* <li><b>A</b> - ASCII alphabetic character: <code>[a-zA-Z]</code>.</li>
|
||||||
|
* <li><b>N</b> - ASCII alphanumeric character: <code>[a-zA-Z0-9]</code>.</li>
|
||||||
|
* <li><b>X</b> - any character except spaces.</li>
|
||||||
|
* <li><b>H</b> - hexadecimal character: <code>[a-fA-F0-9]</code>.</li>
|
||||||
|
* <li><b>D</b> - any digit except zero: <code>[1-9]</code>.</li>
|
||||||
|
* <li><b>9</b> - any digit required: <code>[0-9]</code>.</li>
|
||||||
|
* <li><b>8..1</b> - any digit from 0 to that number, respectively.</li>
|
||||||
|
* <li><b>0</b> - zero only.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p><h3>Behavior</h3>
|
||||||
|
* Any {@code TextField} with {@code MaskTextFormatter} applied shows a placeholder mask by default.
|
||||||
|
* This is basically the input mask with all mask characters replaced with {@link MaskChar#getPlaceholder()}.
|
||||||
|
* The behavior changes if you set {@link TextField#promptTextProperty()}. In that case placeholder
|
||||||
|
* mask is only displayed when {@code TextField} gets focus and will be hidden after focus lost.
|
||||||
|
* So, the placeholder mask is always displayed when focus is set to the {@code TextField}.
|
||||||
|
* You can replace the placeholder mask with any sensible default simply by changing initial
|
||||||
|
* {@code TextField} text to any string that is valid against the input mask.
|
||||||
|
* <br/><br/>The caret will be positioned before the first not fixed character (see {@link MaskChar#isFixed()})
|
||||||
|
* starting from the beginning of the input mask.<br/><br/>
|
||||||
|
*
|
||||||
|
* <p><h3>Validation</h3>
|
||||||
|
* Validation is out of the {@code MaskTextFormatter} scope. E.g. if one can use {@code "29:59"}
|
||||||
|
* to restrict time picker input then {@code "27:30"} would be a valid input, but obviously an
|
||||||
|
* invalid time. Moreover, remember that partial input like this {@code "22:_9"} is also possible.
|
||||||
|
* Input mask is supposed to assist and guide user input, but can barely cancel the validation
|
||||||
|
* completely.
|
||||||
|
*/
|
||||||
|
public class MaskTextFormatter extends TextFormatter<String> {
|
||||||
|
|
||||||
|
protected final MaskTextFilter filter;
|
||||||
|
|
||||||
|
protected MaskTextFormatter(MaskTextFilter filter) {
|
||||||
|
super(filter);
|
||||||
|
this.filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaskTextFormatter(MaskTextFilter filter, TextField field) {
|
||||||
|
super(filter);
|
||||||
|
this.filter = filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Factory Methods //
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new text field with the provided string input mask.
|
||||||
|
* Use this if you create your controls from Java code and don't need to
|
||||||
|
* modify the default {@link MaskChar} implementation.
|
||||||
|
*/
|
||||||
|
public static TextField createTextField(String mask) {
|
||||||
|
return createTextField(fromString(mask));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new text field with the provided input mask.
|
||||||
|
* Use this if you create your controls from Java code and want to
|
||||||
|
* modify the default {@link MaskChar} implementation.
|
||||||
|
*/
|
||||||
|
public static TextField createTextField(List<MaskChar> mask) {
|
||||||
|
final var field = new TextField();
|
||||||
|
create(field, mask);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new mask text formatter with the provided string input mask and
|
||||||
|
* applies itself to the specified text field. Use this if you create your
|
||||||
|
* controls from FXML and don't need to modify the default {@link MaskChar}
|
||||||
|
* implementation.
|
||||||
|
*/
|
||||||
|
public static MaskTextFormatter create(TextField field, String mask) {
|
||||||
|
return create(field, fromString(mask));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new mask text formatter with the provided input mask and
|
||||||
|
* applies itself to the specified text field. Use this if you create your
|
||||||
|
* controls from FXML and want to modify the default {@link MaskChar} implementation.
|
||||||
|
*/
|
||||||
|
public static MaskTextFormatter create(TextField field, List<MaskChar> mask) {
|
||||||
|
Objects.requireNonNull(field, "Text field can't be null");
|
||||||
|
|
||||||
|
if (mask == null || mask.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Input mask can't be null or empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
final var placeholder = createPlaceholderMask(mask);
|
||||||
|
final var filter = new MaskTextFilter(mask);
|
||||||
|
|
||||||
|
field.focusedProperty().addListener((obs, old, val) -> {
|
||||||
|
var text = field.getText();
|
||||||
|
var prompt = field.getPromptText();
|
||||||
|
|
||||||
|
if (val) {
|
||||||
|
// always show input mask to the user when control is focused and has no text
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
filter.doInternalChange(() -> field.setText(placeholder));
|
||||||
|
|
||||||
|
final int caretPos = IntStream.range(0, mask.size())
|
||||||
|
.filter(i -> !mask.get(i).isFixed())
|
||||||
|
.findFirst()
|
||||||
|
.orElse(0);
|
||||||
|
|
||||||
|
Platform.runLater(() -> {
|
||||||
|
field.deselect();
|
||||||
|
field.positionCaret(caretPos);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// remove the input mask, but only if control prompt text is set
|
||||||
|
if (prompt != null && !prompt.isBlank() && Objects.equals(placeholder, text)) {
|
||||||
|
filter.doInternalChange(() -> field.setText(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
field.promptTextProperty().addListener((obs, old, val) -> {
|
||||||
|
if (val == null || val.isBlank()) {
|
||||||
|
filter.doInternalChange(() -> field.setText(placeholder));
|
||||||
|
} else {
|
||||||
|
filter.doInternalChange(() -> field.setText(""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var formatter = new MaskTextFormatter(filter);
|
||||||
|
field.setTextFormatter(formatter);
|
||||||
|
|
||||||
|
// default text, will be changed after/if prompt text is set
|
||||||
|
filter.doInternalChange(() -> field.setText(placeholder));
|
||||||
|
|
||||||
|
return formatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Helpers //
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
protected static String createPlaceholderMask(String inputMask) {
|
||||||
|
return createPlaceholderMask(fromString(inputMask));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static String createPlaceholderMask(List<MaskChar> mask) {
|
||||||
|
return mask.stream()
|
||||||
|
.map(mc -> Character.toString(mc.getPlaceholder()))
|
||||||
|
.collect(Collectors.joining());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static List<MaskChar> fromString(String inputMask) {
|
||||||
|
if (inputMask == null || inputMask.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Input mask can't be null or empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var mask = new ArrayList<MaskChar>(inputMask.trim().length());
|
||||||
|
|
||||||
|
for (int i = 0; i < inputMask.length(); i++) {
|
||||||
|
char curChar = inputMask.charAt(i);
|
||||||
|
SimpleMaskChar maskChar = switch (curChar) {
|
||||||
|
case MaskChar.INPUT_MASK_LETTER -> new SimpleMaskChar(Character::isLetter);
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_OR_LETTER -> new SimpleMaskChar(Character::isLetterOrDigit);
|
||||||
|
case MaskChar.INPUT_MASK_ANY_NON_SPACE -> new SimpleMaskChar(ch -> !Character.isSpaceChar(ch));
|
||||||
|
case MaskChar.INPUT_MASK_HEX -> new SimpleMaskChar(ch ->
|
||||||
|
(ch >= 'A' && ch <= 'F') || (ch >= 'a' && ch <= 'f') || Character.isDigit(ch)
|
||||||
|
);
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_NON_ZERO -> new SimpleMaskChar(ch -> Character.isDigit(ch) && ch != '0');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT -> new SimpleMaskChar(Character::isDigit);
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_8 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '8');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_7 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '7');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_6 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '6');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_5 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '5');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_4 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '4');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_3 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '3');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_2 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '2');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_0_TO_1 -> new SimpleMaskChar(ch -> ch >= '0' && ch <= '1');
|
||||||
|
case MaskChar.INPUT_MASK_DIGIT_ZERO -> new SimpleMaskChar(ch -> ch == '0');
|
||||||
|
default -> SimpleMaskChar.fixed(curChar);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
mask.add(maskChar);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Collections.unmodifiableList(mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Filter Implementation //
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
protected static class MaskTextFilter implements UnaryOperator<Change> {
|
||||||
|
|
||||||
|
protected final List<MaskChar> mask;
|
||||||
|
protected boolean ignoreFilter;
|
||||||
|
|
||||||
|
public MaskTextFilter(List<MaskChar> mask) {
|
||||||
|
this.mask = Objects.requireNonNull(mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInternalChange() {
|
||||||
|
return ignoreFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void doInternalChange(Runnable r) {
|
||||||
|
try {
|
||||||
|
this.ignoreFilter = true;
|
||||||
|
r.run();
|
||||||
|
} finally {
|
||||||
|
this.ignoreFilter = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Change apply(Change change) {
|
||||||
|
if (!change.isContentChange() || isInternalChange()) {
|
||||||
|
return change;
|
||||||
|
}
|
||||||
|
|
||||||
|
return correctContentChange(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects and returns the content change if it matches the mask.
|
||||||
|
* Otherwise, returns null to drop the change.
|
||||||
|
*/
|
||||||
|
protected @Nullable Change correctContentChange(Change change) {
|
||||||
|
String newText = null;
|
||||||
|
|
||||||
|
if (change.isReplaced()) {
|
||||||
|
newText = correctReplacedText(change);
|
||||||
|
} else if (change.isAdded()) {
|
||||||
|
newText = correctAddedText(change);
|
||||||
|
} else if (change.isDeleted()) {
|
||||||
|
newText = correctDeletedText(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newText != null) {
|
||||||
|
int start = change.getRangeStart();
|
||||||
|
change.setRange(start, Math.min(start + newText.length(), change.getControlText().length()));
|
||||||
|
change.setText(newText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only adjust caret pos for content changes, as it allows
|
||||||
|
// to select and copy arbitrary part of the text including fixed chars
|
||||||
|
adjustCaretPosition(change);
|
||||||
|
|
||||||
|
return newText != null ? change : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects the replaced text. For any replaced character it checks whether it matches
|
||||||
|
* the mask character at the same position and applies {@link MaskChar#transform(char)}.
|
||||||
|
* if true. If not, returns null, thus signifying that added text is not valid.
|
||||||
|
*/
|
||||||
|
protected @Nullable String correctReplacedText(Change change) {
|
||||||
|
final int start = change.getRangeStart();
|
||||||
|
final int end = change.getRangeEnd();
|
||||||
|
final var changedText = change.getText();
|
||||||
|
final var newText = new StringBuilder(end - start);
|
||||||
|
|
||||||
|
// replaces new text with transformed according to mask
|
||||||
|
// or exits if the new text doesn't match the mask
|
||||||
|
for (int i = start; i - start < changedText.length() && i < end && i < mask.size(); i++) {
|
||||||
|
final char ch = changedText.charAt(i - start);
|
||||||
|
if (mask.get(i).isAllowed(ch)) {
|
||||||
|
newText.append(mask.get(i).transform(ch));
|
||||||
|
} else {
|
||||||
|
return null; // mark all replaced text as invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// replace is basically 'remove + add' and this handles the situation when
|
||||||
|
// removed text length is greater than added text length, e.g. select 'abc' and type 'd',
|
||||||
|
// in that case the rest of the text (bc) should be replaced with placeholders
|
||||||
|
for (int i = start + changedText.length(); i < end && i < mask.size(); i++) {
|
||||||
|
newText.append(mask.get(i).getPlaceholder());
|
||||||
|
}
|
||||||
|
|
||||||
|
return newText.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects added text. For any input character it checks whether it matches
|
||||||
|
* the mask character at the same position and applies {@link MaskChar#transform(char)}.
|
||||||
|
* if true. If not, returns null, thus signifying that added text is not valid.
|
||||||
|
*/
|
||||||
|
protected @Nullable String correctAddedText(Change change) {
|
||||||
|
final int start = change.getRangeStart();
|
||||||
|
final var changedText = change.getText();
|
||||||
|
final var newText = new StringBuilder(changedText.length());
|
||||||
|
|
||||||
|
for (int i = start; i - start < changedText.length() && i < mask.size(); i++) {
|
||||||
|
final char ch = changedText.charAt(i - start);
|
||||||
|
|
||||||
|
if (mask.get(i).isAllowed(ch)) {
|
||||||
|
newText.append(mask.get(i).transform(ch));
|
||||||
|
} else {
|
||||||
|
return null; // mark all added text as invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newText.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects the deleted text. Basically, replaces all deleted characters with
|
||||||
|
* placeholders and returns the resulting text which is always not null.
|
||||||
|
*/
|
||||||
|
protected String correctDeletedText(Change change) {
|
||||||
|
int start = change.getRangeStart();
|
||||||
|
final int end = change.getRangeEnd();
|
||||||
|
final var newText = new StringBuilder(end - start);
|
||||||
|
|
||||||
|
// replaces deleted text with placeholders
|
||||||
|
for (int i = start; i < end; i++) {
|
||||||
|
newText.append(mask.get(i).getPlaceholder());
|
||||||
|
}
|
||||||
|
|
||||||
|
// handles the situation when backspace is pressed to delete a fixes char (separator),
|
||||||
|
// in that case the character before the separator, if any, should be removed
|
||||||
|
for (int i = start; i > 0 && mask.get(i).isFixed(); i--, start--) {
|
||||||
|
newText.insert(0, mask.get(i - 1).getPlaceholder());
|
||||||
|
}
|
||||||
|
|
||||||
|
change.setRange(start, end);
|
||||||
|
|
||||||
|
return newText.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void adjustCaretPosition(Change change) {
|
||||||
|
final int oldPos = change.getControlCaretPosition();
|
||||||
|
int newPos = Math.min(change.getCaretPosition(), mask.size());
|
||||||
|
|
||||||
|
if (oldPos != newPos) {
|
||||||
|
// caret can't be placed before a fixed character,
|
||||||
|
// it jumps over it to the previous or the next character
|
||||||
|
final int sign = newPos > oldPos ? 1 : -1;
|
||||||
|
while (newPos > 0 && newPos < mask.size() && mask.get(newPos).isFixed()) {
|
||||||
|
newPos += sign;
|
||||||
|
}
|
||||||
|
|
||||||
|
// prevents caret from moving before a fixed prefix,
|
||||||
|
while (newPos < mask.size() && mask.get(newPos).isFixed()) {
|
||||||
|
newPos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// make sure caret position won't exceed control text length
|
||||||
|
newPos = Math.min(newPos, change.getControlNewText().length());
|
||||||
|
|
||||||
|
if (change.getAnchor() == change.getCaretPosition()) {
|
||||||
|
change.setAnchor(newPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
change.setCaretPosition(newPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
68
base/src/main/java/atlantafx/base/util/SimpleMaskChar.java
Normal file
68
base/src/main/java/atlantafx/base/util/SimpleMaskChar.java
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
/* SPDX-License-Identifier: MIT */
|
||||||
|
|
||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Predicate;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default {@link MaskChar} implementation that should be suitable
|
||||||
|
* for anything except heavily custom logic.
|
||||||
|
*/
|
||||||
|
final class SimpleMaskChar implements MaskChar {
|
||||||
|
|
||||||
|
private final Predicate<Character> matchExpr;
|
||||||
|
private final UnaryOperator<Character> transform;
|
||||||
|
private final char placeholder;
|
||||||
|
private final boolean fixed;
|
||||||
|
|
||||||
|
public SimpleMaskChar(Predicate<Character> matchExpr) {
|
||||||
|
this(matchExpr, UnaryOperator.identity(), UNDERSCORE, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SimpleMaskChar(Predicate<Character> matchExpr,
|
||||||
|
UnaryOperator<Character> transform) {
|
||||||
|
this(matchExpr, transform, UNDERSCORE, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SimpleMaskChar(Predicate<Character> matchExpr,
|
||||||
|
UnaryOperator<Character> transform,
|
||||||
|
char placeholder) {
|
||||||
|
this(matchExpr, transform, placeholder, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public SimpleMaskChar(Predicate<Character> matchExpr,
|
||||||
|
UnaryOperator<Character> transform,
|
||||||
|
char placeholder,
|
||||||
|
boolean fixed) {
|
||||||
|
this.matchExpr = Objects.requireNonNull(matchExpr);
|
||||||
|
this.transform = Objects.requireNonNull(transform);
|
||||||
|
this.placeholder = placeholder;
|
||||||
|
this.fixed = fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAllowed(final char ch) {
|
||||||
|
return matchExpr.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public char transform(final char ch) {
|
||||||
|
return transform.apply(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public char getPlaceholder() {
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isFixed() {
|
||||||
|
return fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SimpleMaskChar fixed(char ch) {
|
||||||
|
return new SimpleMaskChar(c -> c == ch, UnaryOperator.identity(), ch, true);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,197 @@
|
|||||||
|
package atlantafx.base.util;
|
||||||
|
|
||||||
|
import static atlantafx.base.util.MaskTextFormatter.createPlaceholderMask;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
|
||||||
|
|
||||||
|
import atlantafx.base.JavaFXTest;
|
||||||
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
|
import javafx.scene.control.TextFormatter.Change;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
|
||||||
|
@ExtendWith({JavaFXTest.class})
|
||||||
|
public class MaskTextFormatterTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDefaultTextIsPlaceholderMask() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testValidInitialTextSet() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+11-22");
|
||||||
|
assertThat(field.getText()).isEqualTo("+11-22");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInvalidInitialTextRejected() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("123");
|
||||||
|
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testPromptTextResetsPlaceholderMask() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+11-22");
|
||||||
|
field.setPromptText("whatever");
|
||||||
|
assertThat(field.getText()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This one is a #javafx-bug. Calling the {@link Change#getControlNewText()}
|
||||||
|
* in text formatter when text is set to null produces IndexOutOfBoundsException
|
||||||
|
* or IllegalArgumentException (start must <= end). Ironically, there won't be
|
||||||
|
* exception if you made some text manipulations first, like:
|
||||||
|
* <pre>
|
||||||
|
* field.setText("foo");
|
||||||
|
* field.deleteText(0, 1);
|
||||||
|
* field.setText(null); // no exception here
|
||||||
|
* </pre>
|
||||||
|
* It's worth to mention that the default text field value is empty string precisely,
|
||||||
|
* not null.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void testInitialTextCanNotBeNull() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
assertThatThrownBy(() -> field.setText(null)).isInstanceOf(IndexOutOfBoundsException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testAppendText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText(createPlaceholderMask("+DD-DD"));
|
||||||
|
|
||||||
|
field.appendText("12");
|
||||||
|
assertThat(field.getText()).isEqualTo("+__-__");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInsertText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText(createPlaceholderMask("+DD-DD"));
|
||||||
|
|
||||||
|
field.insertText(1, "12");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-__");
|
||||||
|
|
||||||
|
field.insertText(4, "34");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-34");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testInsertInvalidText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText(createPlaceholderMask("+DD-DD"));
|
||||||
|
|
||||||
|
field.insertText(1, "12");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-__");
|
||||||
|
|
||||||
|
field.insertText(4, "A2");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-__");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDeleteSomeText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.deleteText(2, 3);
|
||||||
|
assertThat(field.getText()).isEqualTo("+1_-34");
|
||||||
|
|
||||||
|
field.deleteText(4, 6);
|
||||||
|
assertThat(field.getText()).isEqualTo("+1_-__");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDeleteAllText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.deleteText(0, field.getText().length());
|
||||||
|
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testSetTextToNull() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.deleteText(2, 3);
|
||||||
|
assertThat(field.getText()).isEqualTo("+1_-34");
|
||||||
|
|
||||||
|
field.setText(null);
|
||||||
|
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReplaceSelection() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.selectRange(1, 3);
|
||||||
|
field.replaceSelection("56");
|
||||||
|
assertThat(field.getText()).isEqualTo("+56-34");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReplaceSelectionWithInvalidText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.selectRange(1, 3);
|
||||||
|
field.replaceSelection("A2");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-34");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReplaceAll() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.selectRange(0, field.getText().length());
|
||||||
|
field.replaceSelection("+43-21");
|
||||||
|
assertThat(field.getText()).isEqualTo("+43-21");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testReplaceAllWithInvalidText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
field.setText("+12-34");
|
||||||
|
|
||||||
|
field.selectRange(0, field.getText().length());
|
||||||
|
field.replaceSelection("+A2-21");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-34");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testBindingValidText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
var prop = new SimpleStringProperty();
|
||||||
|
prop.bindBidirectional(field.textProperty());
|
||||||
|
|
||||||
|
assertThat(prop.get()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
|
||||||
|
prop.set("+12-34");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-34");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testBindingInvalidText() {
|
||||||
|
var field = MaskTextFormatter.createTextField("+DD-DD");
|
||||||
|
var prop = new SimpleStringProperty();
|
||||||
|
prop.bindBidirectional(field.textProperty());
|
||||||
|
|
||||||
|
assertThat(prop.get()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
assertThat(field.getText()).isEqualTo(createPlaceholderMask("+DD-DD"));
|
||||||
|
|
||||||
|
prop.set("+12-34");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-34");
|
||||||
|
|
||||||
|
prop.set("+A2-34");
|
||||||
|
assertThat(field.getText()).isEqualTo("+12-34");
|
||||||
|
}
|
||||||
|
}
|
@ -236,7 +236,7 @@ class Sidebar extends StackPane {
|
|||||||
navLink(CheckBoxPage.NAME, CheckBoxPage.class),
|
navLink(CheckBoxPage.NAME, CheckBoxPage.class),
|
||||||
navLink(ColorPickerPage.NAME, ColorPickerPage.class),
|
navLink(ColorPickerPage.NAME, ColorPickerPage.class),
|
||||||
navLink(ComboBoxPage.NAME, ComboBoxPage.class, "ChoiceBox"),
|
navLink(ComboBoxPage.NAME, ComboBoxPage.class, "ChoiceBox"),
|
||||||
navLink(CustomTextFieldPage.NAME, CustomTextFieldPage.class),
|
navLink(CustomTextFieldPage.NAME, CustomTextFieldPage.class, "MaskTextField"),
|
||||||
navLink(DatePickerPage.NAME, DatePickerPage.class),
|
navLink(DatePickerPage.NAME, DatePickerPage.class),
|
||||||
navLink(DialogPage.NAME, DialogPage.class),
|
navLink(DialogPage.NAME, DialogPage.class),
|
||||||
navLink(HtmlEditorPage.NAME, HtmlEditorPage.class),
|
navLink(HtmlEditorPage.NAME, HtmlEditorPage.class),
|
||||||
|
@ -4,15 +4,25 @@ package atlantafx.sampler.page.components;
|
|||||||
|
|
||||||
import static atlantafx.base.theme.Styles.STATE_DANGER;
|
import static atlantafx.base.theme.Styles.STATE_DANGER;
|
||||||
import static atlantafx.base.theme.Styles.STATE_SUCCESS;
|
import static atlantafx.base.theme.Styles.STATE_SUCCESS;
|
||||||
|
import static atlantafx.sampler.page.SampleBlock.BLOCK_HGAP;
|
||||||
|
|
||||||
import atlantafx.base.controls.CustomTextField;
|
import atlantafx.base.controls.CustomTextField;
|
||||||
|
import atlantafx.base.controls.MaskTextField;
|
||||||
import atlantafx.base.util.PasswordTextFormatter;
|
import atlantafx.base.util.PasswordTextFormatter;
|
||||||
import atlantafx.sampler.page.AbstractPage;
|
import atlantafx.sampler.page.AbstractPage;
|
||||||
import atlantafx.sampler.page.SampleBlock;
|
import atlantafx.sampler.page.SampleBlock;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javafx.geometry.Pos;
|
||||||
import javafx.scene.Cursor;
|
import javafx.scene.Cursor;
|
||||||
|
import javafx.scene.control.Label;
|
||||||
import javafx.scene.layout.FlowPane;
|
import javafx.scene.layout.FlowPane;
|
||||||
|
import javafx.scene.layout.HBox;
|
||||||
|
import javafx.scene.layout.VBox;
|
||||||
import org.kordamp.ikonli.feather.Feather;
|
import org.kordamp.ikonli.feather.Feather;
|
||||||
import org.kordamp.ikonli.javafx.FontIcon;
|
import org.kordamp.ikonli.javafx.FontIcon;
|
||||||
|
import org.kordamp.ikonli.material2.Material2OutlinedAL;
|
||||||
|
import org.kordamp.ikonli.material2.Material2OutlinedMZ;
|
||||||
|
|
||||||
public class CustomTextFieldPage extends AbstractPage {
|
public class CustomTextFieldPage extends AbstractPage {
|
||||||
|
|
||||||
@ -33,7 +43,8 @@ public class CustomTextFieldPage extends AbstractPage {
|
|||||||
bothIconsSample(),
|
bothIconsSample(),
|
||||||
successSample(),
|
successSample(),
|
||||||
dangerSample(),
|
dangerSample(),
|
||||||
passwordSample()
|
passwordSample(),
|
||||||
|
maskSample()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,4 +110,41 @@ public class CustomTextFieldPage extends AbstractPage {
|
|||||||
|
|
||||||
return new SampleBlock("Password", tf);
|
return new SampleBlock("Password", tf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private SampleBlock maskSample() {
|
||||||
|
var phoneField = new MaskTextField("(999) 999 99 99");
|
||||||
|
phoneField.setPromptText("(999) 999 99 99");
|
||||||
|
phoneField.setLeft(new FontIcon(Material2OutlinedMZ.PHONE));
|
||||||
|
phoneField.setPrefWidth(180);
|
||||||
|
|
||||||
|
var cardField = new MaskTextField("9999-9999-9999-9999");
|
||||||
|
cardField.setLeft(new FontIcon(Material2OutlinedAL.CREDIT_CARD));
|
||||||
|
cardField.setPrefWidth(200);
|
||||||
|
|
||||||
|
var timeFormatter = DateTimeFormatter.ofPattern("HH:mm");
|
||||||
|
var timeField = new MaskTextField("29:59");
|
||||||
|
timeField.setText(LocalTime.now().format(timeFormatter));
|
||||||
|
timeField.setLeft(new FontIcon(Material2OutlinedMZ.TIMER));
|
||||||
|
timeField.setPrefWidth(120);
|
||||||
|
timeField.textProperty().addListener((obs, old, val) -> {
|
||||||
|
if (val != null) {
|
||||||
|
try {
|
||||||
|
LocalTime.parse(val, timeFormatter);
|
||||||
|
timeField.pseudoClassStateChanged(STATE_DANGER, false);
|
||||||
|
} catch (Exception e) {
|
||||||
|
timeField.pseudoClassStateChanged(STATE_DANGER, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var content = new HBox(
|
||||||
|
BLOCK_HGAP,
|
||||||
|
new VBox(5, new Label("Phone Number"), phoneField),
|
||||||
|
new VBox(5, new Label("Bank Card"), cardField),
|
||||||
|
new VBox(5, new Label("Time"), timeField)
|
||||||
|
);
|
||||||
|
content.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
return new SampleBlock("Input Mask", content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user