diff -r eb6beaecf92d modules/base/src/main/java/javafx/util/converter/FormatStringConverter.java --- a/modules/base/src/main/java/javafx/util/converter/FormatStringConverter.java Tue Jun 17 10:54:58 2014 +0300 +++ b/modules/base/src/main/java/javafx/util/converter/FormatStringConverter.java Tue Jun 17 14:40:16 2014 +0200 @@ -51,27 +51,28 @@ /** {@inheritDoc} */ @Override public T fromString(String value) { - try { - // If the specified value is null or zero-length, return null - if (value == null) { - return null; - } + // If the specified value is null or zero-length, return null + if (value == null) { + return null; + } - value = value.trim(); + value = value.trim(); - if (value.length() < 1) { - return null; - } + if (value.length() < 1) { + return null; + } - // Create and configure the parser to be used - Format _format = getFormat(); + // Create and configure the parser to be used + Format _format = getFormat(); - // Perform the requested parsing, and attempt to conver the output - // back to T - return (T) _format.parseObject(value); - } catch (ParseException ex) { - throw new RuntimeException(ex); + // Perform the requested parsing, and attempt to conver the output + // back to T + final ParsePosition pos = new ParsePosition(0); + T result = (T) _format.parseObject(value, pos); + if (pos.getIndex() != value.length()) { + throw new RuntimeException("Parsed string not according to the format"); } + return result; } /** {@inheritDoc} */ diff -r eb6beaecf92d modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextAreaBehavior.java --- a/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextAreaBehavior.java Tue Jun 17 10:54:58 2014 +0300 +++ b/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextAreaBehavior.java Tue Jun 17 14:40:16 2014 +0200 @@ -258,21 +258,11 @@ private void insertNewLine() { TextArea textArea = getControl(); - IndexRange selection = textArea.getSelection(); - int start = selection.getStart(); - int end = selection.getEnd(); - - getUndoManager().addChange(start, textArea.textProperty().getValueSafe().substring(start, end), "\n", false); textArea.replaceSelection("\n"); } private void insertTab() { TextArea textArea = getControl(); - IndexRange selection = textArea.getSelection(); - int start = selection.getStart(); - int end = selection.getEnd(); - - getUndoManager().addChange(start, textArea.textProperty().getValueSafe().substring(start, end), "\t", false); textArea.replaceSelection("\t"); } @@ -288,7 +278,6 @@ lineStart(false, false); int start = textArea.getCaretPosition(); if (end > start) { - getUndoManager().addChange(start, textArea.textProperty().getValueSafe().substring(start, end), null); replaceText(start, end, ""); } } diff -r eb6beaecf92d modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextFieldBehavior.java --- a/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextFieldBehavior.java Tue Jun 17 10:54:58 2014 +0300 +++ b/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextFieldBehavior.java Tue Jun 17 14:40:16 2014 +0200 @@ -196,7 +196,6 @@ int end = textField.getCaretPosition(); if (end > 0) { - getUndoManager().addChange(0, textField.textProperty().getValueSafe().substring(0, end), null); replaceText(0, end, ""); } } diff -r eb6beaecf92d modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextInputControlBehavior.java --- a/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextInputControlBehavior.java Tue Jun 17 10:54:58 2014 +0300 +++ b/modules/controls/src/main/java/com/sun/javafx/scene/control/behavior/TextInputControlBehavior.java Tue Jun 17 14:40:16 2014 +0200 @@ -25,6 +25,9 @@ package com.sun.javafx.scene.control.behavior; +import java.text.Bidi; +import java.util.ArrayList; +import java.util.List; import javafx.application.ConditionalFeature; import javafx.beans.InvalidationListener; import javafx.geometry.NodeOrientation; @@ -40,6 +43,8 @@ import com.sun.javafx.application.PlatformImpl; import com.sun.javafx.scene.control.skin.TextInputControlSkin; +import javax.swing.undo.UndoManager; + import static javafx.scene.input.KeyEvent.KEY_PRESSED; import static com.sun.javafx.PlatformUtil.*; @@ -71,15 +76,9 @@ */ private KeyEvent lastEvent; - private UndoManager undoManager = new UndoManager(); - private BreakIterator charIterator; private InvalidationListener textListener = observable -> { - if (!isEditing()) { - // Text changed, but not by user action - undoManager.reset(); - } invalidateBidi(); }; @@ -153,8 +152,8 @@ else if ("DeletePreviousWord".equals(name)) deletePreviousWord(); else if ("DeleteNextWord".equals(name)) deleteNextWord(); else if ("DeleteSelection".equals(name)) deleteSelection(); - else if ("Undo".equals(name)) undoManager.undo(); - else if ("Redo".equals(name)) undoManager.redo(); + else if ("Undo".equals(name)) textInputControl.undo(); + else if ("Redo".equals(name)) textInputControl.redo(); else { done = false; } @@ -207,10 +206,6 @@ // Note, I don't have to worry about "Consume" here. } - protected UndoManager getUndoManager() { - return undoManager; - } - /** * The default handler for a key typed event, which is called when none of * the other key bindings match. This is the method which handles basic @@ -246,7 +241,6 @@ // + character.length() > textInput.getMaximumLength()) { // // TODO Beep? // } else { - undoManager.addChange(start, textInput.textProperty().getValueSafe().substring(start, end), character, true); replaceText(start, end, character); // } @@ -320,49 +314,10 @@ } private void deletePreviousChar() { - TextInputControl textInputControl = getControl(); - IndexRange selection = textInputControl.getSelection(); - int start = selection.getStart(); - int end = selection.getEnd(); - - if (start > 0 || end > start) { - if (selection.getLength() == 0) { - end = start; - // Note: This can handle the case of a surrogate pair - // which requires two chars to be deleted. However it - // does not, and should not, delete any other kind of - // cluster as a whole, just the last char. Compare - // with how deleteNextChar() behaves. See also the - // comment in TextInputControl.deletePreviousChar(). - // So, do not use charIterator here. - start = Character.offsetByCodePoints(textInputControl.getText(), end, -1); - } - undoManager.addChange(start, textInputControl.getText().substring(start, end), null); - } deleteChar(true); } private void deleteNextChar() { - TextInputControl textInputControl = getControl(); - IndexRange selection = textInputControl.getSelection(); - int start = selection.getStart(); - int end = selection.getEnd(); - - if (start < textInputControl.getLength() || end > start) { - if (selection.getLength() == 0) { - // Note: This can handle the case of a surrogate - // pair which requires two chars to be deleted. - // It will also delete any kind of cluster or ligature as - // a whole, not just the first char. Compare with - // deletePreviousChar(). - if (charIterator == null) { - charIterator = BreakIterator.getCharacterInstance(); - } - charIterator.setText(textInputControl.getText()); - end = charIterator.following(start); - } - undoManager.addChange(start, textInputControl.getText().substring(start, end), null); - } deleteChar(false); } @@ -373,7 +328,6 @@ if (end > 0) { textInputControl.previousWord(); int start = textInputControl.getCaretPosition(); - undoManager.addChange(start, textInputControl.getText().substring(start, end), null); replaceText(start, end, ""); } } @@ -385,7 +339,6 @@ if (start < textInputControl.getLength()) { nextWord(); int end = textInputControl.getCaretPosition(); - undoManager.addChange(start, textInputControl.getText().substring(start, end), null); replaceText(start, end, ""); } } @@ -395,38 +348,18 @@ IndexRange selection = textInputControl.getSelection(); if (selection.getLength() > 0) { - int start = selection.getStart(); - int end = selection.getEnd(); - undoManager.addChange(start, textInputControl.getText().substring(start, end), null); deleteChar(false); } } private void cut() { TextInputControl textInputControl = getControl(); - IndexRange selection = textInputControl.getSelection(); - - if (selection.getLength() > 0) { - int start = selection.getStart(); - int end = selection.getEnd(); - undoManager.addChange(start, textInputControl.getText().substring(start, end), null); - } textInputControl.cut(); } private void paste() { TextInputControl textInputControl = getControl(); - IndexRange selection = textInputControl.getSelection(); - int start = selection.getStart(); - int end = selection.getEnd(); - String text = textInputControl.textProperty().getValueSafe(); - String deleted = text.substring(start, end); - int tail = text.length() - end; - textInputControl.paste(); - - text = textInputControl.textProperty().getValueSafe(); - undoManager.addChange(start, deleted, text.substring(start, text.length() - tail)); } protected void selectPreviousWord() { @@ -529,104 +462,4 @@ public boolean isEditing() { return editing; } - - public boolean canUndo() { - return undoManager.canUndo(); - } - - public boolean canRedo() { - return undoManager.canRedo(); - } - - static class Change { - int start; - String oldText; - String newText; - boolean appendable; - - Change(int start, String oldText, String newText) { - this(start, oldText, newText, false); - } - - Change(int start, String oldText, String newText, boolean appendable) { - this.start = start; - this.oldText = oldText; - this.newText = newText; - this.appendable = appendable; - } - } - - class UndoManager { - private ArrayList chain = new ArrayList(); - private int currentIndex = 0; - - public void addChange(int start, String oldText, String newText) { - addChange(start, oldText, newText, false); - } - - public void addChange(int start, String oldText, String newText, boolean appendable) { - truncate(); - if (appendable && currentIndex > 0 && (oldText == null || oldText.length() == 0)) { - Change change = chain.get(currentIndex - 1); - if (change.appendable && start == change.start + change.newText.length()) { - // Append text to previous Change - change.newText += newText; - return; - } - } - chain.add(new Change(start, oldText, newText, appendable)); - currentIndex++; - } - - public void undo() { - if (currentIndex > 0) { - // Apply reverse change here - Change change = chain.get(currentIndex - 1); - replaceText(change.start, - change.start + ((change.newText != null) ? change.newText.length() : 0), - (change.oldText != null) ? change.oldText : ""); - currentIndex--; - if (currentIndex > 0) { - chain.get(currentIndex - 1).appendable = false; - } - } - // else beep ? - } - - public void redo() { - if (currentIndex < chain.size()) { - // Apply change here - Change change = chain.get(currentIndex); - replaceText(change.start, - change.start + ((change.oldText != null) ? change.oldText.length() : 0), - (change.newText != null) ? change.newText : ""); - change.appendable = false; - currentIndex++; - } - // else beep ? - } - - public boolean canUndo() { - return (currentIndex > 0); - } - - public boolean canRedo() { - return (currentIndex < chain.size()); - } - - public void reset() { - chain.clear(); - currentIndex = 0; - } - - private void truncate() { - if (currentIndex > 0 && chain.size() > currentIndex) { - chain.get(currentIndex - 1).appendable = false; - } - - while (chain.size() > currentIndex) { - chain.remove(chain.size() - 1); - } - } - } } diff -r eb6beaecf92d modules/controls/src/main/java/com/sun/javafx/scene/control/skin/TextInputControlSkin.java --- a/modules/controls/src/main/java/com/sun/javafx/scene/control/skin/TextInputControlSkin.java Tue Jun 17 10:54:58 2014 +0300 +++ b/modules/controls/src/main/java/com/sun/javafx/scene/control/skin/TextInputControlSkin.java Tue Jun 17 14:40:16 2014 +0200 @@ -717,8 +717,8 @@ } else { items.setAll(copyMI, separatorMI, selectAllMI); } - undoMI.setDisable(!getBehavior().canUndo()); - redoMI.setDisable(!getBehavior().canRedo()); + undoMI.setDisable(!getSkinnable().isUndoable()); + redoMI.setDisable(!getSkinnable().isRedoable()); cutMI.setDisable(maskText || !hasSelection); copyMI.setDisable(maskText || !hasSelection); pasteMI.setDisable(!Clipboard.getSystemClipboard().hasString()); diff -r eb6beaecf92d modules/controls/src/main/java/javafx/scene/control/FormattedTextField.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/modules/controls/src/main/java/javafx/scene/control/FormattedTextField.java Tue Jun 17 14:40:16 2014 +0200 @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2011, 2014, 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 javafx.scene.control; + +import javafx.beans.property.*; +import javafx.event.ActionEvent; +import javafx.util.StringConverter; + +/** + * FormattedTextField is a special kind of TextField that handles conversion between a value of type {@code T} and + * a text of this TextField. + *

+ * There are two types of converters: + *

+ * + *

+ * When editing the text field, the value is not updated until either {@code ENTER} key is pressed or focus is lost. + * If the conversion fail, the last known valid value is used instead. + * + * @param the value type + */ +public final class FormattedTextField extends TextField{ + + /** + * Creates a formatted text field with the specified converter and a null value. + * @param valueConverter the value converter + */ + public FormattedTextField(StringConverter valueConverter) { + this(valueConverter, null); + } + + /** + * Creates a formatted text field with the specified converter and a value + * @param valueConverter the value converter + * @param value the initial value + */ + public FormattedTextField(StringConverter valueConverter, T value) { + setValueConverter(valueConverter); + setValue(value); + addEventHandler(ActionEvent.ACTION, (h) -> commit()); + focusedProperty().addListener((ob, o, n) -> { + if (n) { + if (getEditConverter() != null) { + updateText(getValue()); + } + } else { + commit(); + } + }); + } + + private void commit() { + updateValue(); + } + + private void updateValue() { + updateValue(getEditConverter() != null ? getEditConverter() : getValueConverter()); + } + + private void updateValue(StringConverter converter) { + if (converter != null && !value.isBound()) { + try { + T v = converter.fromString(getText()); + setValue(v); + } catch (Exception e) { + //TODO: beep! + //beep(); + updateText(getValue()); // Set the text with the latest value + } + } + } + + private void updateText(T value) { + StringConverter converter; + if (isFocused() && getEditConverter() != null) { + converter = getEditConverter(); + } else { + converter = getValueConverter(); + } + String text = converter != null ? converter.toString(value) : value.toString(); + filterAndSet(text); + } + + @Override + void textUpdated() { + // This method is called from TextInputControl constructor, before our internal fields are initialized. + // We need to filter out this call. + if (valueConverter != null) { + updateValue(getValueConverter()); + } + } + + /** + * This represents the current value of the formatted text field. If a {@link #valueConverterProperty()} is provided, + * and the text field is not being edited, the value is a representation of the text in the text field. + */ + private final ObjectProperty value = new ObjectPropertyBase() { + @Override + public Object getBean() { + return FormattedTextField.this; + } + + @Override + public String getName() { + return "value"; + } + + @Override + protected void invalidated() { + T value = get(); + updateText(value); + } + }; + + public final ObjectProperty valueProperty() { + return value + } + public final void setValue(T value) { + this.value.set(value); + } + public final T getValue() { + return value.get(); + } + + /** + * The default converter between the values and text. + * Note that changing the converter might lead to a change of value, but only if the text can be converted by the new + * converter. Otherwise, the current value is converted to a text, which is set to the field. + * @see #editConverterProperty() + */ + private final ObjectProperty> valueConverter = new ObjectPropertyBase>() { + @Override + public Object getBean() { + return FormattedTextField.this; + } + + @Override + public String getName() { + return "valueConverter"; + } + + @Override + protected void invalidated() { + if (!isFocused() || getEditConverter() == null) { + updateValue(get()); + } + } + }; + public final ObjectProperty> valueConverterProperty() { + return valueConverter; + } + public final void setValueConverter(StringConverter converter) { + valueConverter.set(converter); + } + public final StringConverter getValueConverter() { + return valueConverter.get(); + } + + /** + * Converter between values and text when the field is being edited. + * @see #valueConverterProperty() + */ + private final ObjectProperty> editConverter = new SimpleObjectProperty<>(this, "editConverter"); + public final ObjectProperty> editConverterProperty() { + return editConverter; + } + public final void setEditConverter(StringConverter converter) { + editConverter.set(converter); + } + public final StringConverter getEditConverter() { + return editConverter.get(); + } + +} diff -r eb6beaecf92d modules/controls/src/main/java/javafx/scene/control/TextInputControl.java --- a/modules/controls/src/main/java/javafx/scene/control/TextInputControl.java Tue Jun 17 10:54:58 2014 +0300 +++ b/modules/controls/src/main/java/javafx/scene/control/TextInputControl.java Tue Jun 17 14:40:16 2014 +0200 @@ -32,6 +32,9 @@ import javafx.beans.binding.StringBinding; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ObjectPropertyBase; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; import javafx.beans.property.ReadOnlyIntegerProperty; import javafx.beans.property.ReadOnlyIntegerWrapper; import javafx.beans.property.ReadOnlyObjectProperty; @@ -62,6 +65,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; import com.sun.javafx.Utils; import com.sun.javafx.binding.ExpressionHelper; @@ -110,6 +115,400 @@ public int length(); } + /** + * Content Filter specifies the filter to be used with {@link TextInputControl#contentFilterProperty()}. + * It allow user to intercept and modify any change done to the text content. + * To avoid content that's not valid for the filter, it's possible to assign a default value supplier for the filter. + *

+ * The filter itself is an {@code UnaryOperator} that accepts {@link javafx.scene.control.TextInputControl.ContentChange} object. + * It should return a {@link javafx.scene.control.TextInputControl.ContentChange} object that contains the actual (filtered) + * change. Returning null rejects the change. + *

+ * If default value supplier is provided, it is used when the {@code ContentFilter} is assigned to a {@code TextInputControl} + * and it's current text is invalid. It is expected that the provided default value is accepted by the filtering operator. + */ + public static class ContentFilter { + + private final UnaryOperator filter; + private final Supplier defaultValue; + + /** + * Creates a new filter with the providing filtering operator. + * @param filter the filtering operator + * + * @throws java.lang.NullPointerException if filter is null + */ + public ContentFilter(UnaryOperator filter) { + this(filter, null); + } + + /** + * Creates a new filter with the providing filtering operator and default value supplier. + * @param filter the filtering operator + * @param defaultValue the default value or null + * + * @throws java.lang.NullPointerException if filter is null + */ + public ContentFilter(UnaryOperator filter, Supplier defaultValue) { + if (filter == null) { + throw new NullPointerException("Filter cannot be null"); + } + this.filter = filter; + this.defaultValue = defaultValue; + } + + /** + * The filtering operator of this filter. + * @return the operator + */ + public UnaryOperator getFilter() { + return filter; + } + + /** + * The default value provider of this filter + * @return the default value provider or null + */ + public Supplier getDefaultValue() { + return defaultValue; + } + + /** + * Chains this filter with a filtering operator. The other filtering operator is used only if this + * filter's operator rejects the operation. The default value of the new {@code ContentFilter} is the same + * as of this filter. + * @param other the filtering operator to chain + * @return a new ContentFilter as described above + */ + public ContentFilter orApply(UnaryOperator other) { + return new ContentFilter((ContentChange change) -> { + ContentChange c = filter.apply(change.clone()); + return c != null ? c : other.apply(change); + }, defaultValue); + } + + /** + * Chains this filter with a filtering operator of another {@code ContentFilter}. The other filtering operator is used only if this + * filter's operator rejects the operation. The default value of the new {@code ContentFilter} is the same + * as of this filter. + * @param other the filter to chain + * @return a new ContentFilter as described above + */ + public ContentFilter orApply(ContentFilter other) { + return orApply(other.filter); + } + + } + + /** + * Contains the state representing a change in the content for a + * TextInputControl. This object is passed to any registered + * {@code contentFilter} on the TextInputControl whenever the text + * for the TextInputControl is modified. + *

+ * This class contains state and convenience methods for determining what + * change occurred on the control. It also has a reference to the + * TextInputControl itself so that the developer may query any other + * state on the control. Note that you should never modify the state + * of the control directly from within the contentFilter handler. + *

+ *

+ * The ContentChange is mutable, but not observable. It should be used + * only for the life of a single change. It is intended that the + * ContentChange will be modified from within the contentFilter. + *

+ */ + public static final class ContentChange implements Cloneable{ + private TextInputControl control; + private int start; + private int end; + private String text; + private int newAnchor; + private int newCaretPosition; + + private final boolean added; + private final boolean deleted; + private final boolean inserted; + private final boolean appended; + private final boolean prepended; + private final boolean replaced; + + // Restrict construction to TextInputControl only. Because we are the + // only ones who can create this, we don't bother doing a check here + // to make sure the arguments are within reason (they will be). + ContentChange(TextInputControl control, int start, int end, String text) { + this.control = control; + this.start = start; + this.end = end; + this.text = text; + this.newAnchor = this.newCaretPosition = start + text.length(); + deleted = text.isEmpty(); + replaced = !deleted && start != end; + added = !deleted && !replaced; + appended = added && start == control.getLength(); + prepended = added && start == 0; + inserted = added && !appended && !prepended; + } + + /** + * Gets the control associated with this change. + * @return The control associated with this change. This will never be null. + */ + public final TextInputControl getControl() { return control; } + + /** + * Gets the start index into the {@link javafx.scene.control.TextInputControl#getText()} + * for the modification. This will always be a value > 0 and + * <= {@link javafx.scene.control.TextInputControl#getLength()}. + * + * @return The start index + */ + public final int getStart() { return start; } + + /** + * Sets the start index for the range to be modified. This value must + * always be a value > 0 and <= {@link javafx.scene.control.TextInputControl#getLength()} + *

+ * Note that setting start before the current end will also change end to the same value. + * + * @param value the new start index + */ + public final void setStart(int value) { + if (value < 0 || value > control.getLength()) { + throw new IndexOutOfBoundsException(); + } + if (end < start) { + end = value; + } + start = value; + } + + /** + * Gets the end index into the {@link javafx.scene.control.TextInputControl#getText()} + * for the modification. This will always be a value > {@link #getStart()} and + * <= {@link javafx.scene.control.TextInputControl#getLength()}. + * + * @return The end index + */ + public final int getEnd() { return end; } + + /** + * Sets the end index for the range to be modified. This value must + * always be a value > {@link #getStart()} and <= {@link javafx.scene.control.TextInputControl#getLength()}. + * Note that there is an order dependency between modifying the start + * and end values. They must always be modified in order such that they + * do not violate their constraints. + * + * @param value The new end index + */ + public final void setEnd(int value) { + if (value < start || value > control.getLength()) { + throw new IndexOutOfBoundsException(); + } + end = value; + } + + /** + * A convenience method returning an IndexRange representing the + * start and end values. + * + * @return a non-null IndexRange representing the range from start to end. + */ + public final IndexRange getRange() { return new IndexRange(start, end); } + + /** + * A convenience method assigning both the start and end values + * together, in such a way as to ensure they are valid with respect to + * each other. One way to use this method is to set the range like this: + * {@code + * change.setRange(IndexRange.normalize(newStart, newEnd)); + * } + * + * @param value The new range. Cannot be null. + */ + public final void setRange(IndexRange value) { + setRange(value.getStart(), value.getEnd()); + } + + /** + * A convenience method assigning both the start and end values + * together, in such a way as to ensure they are valid with respect to + * each other. The start must be less than or equal to the end. + * + * @param start The new start value. Must be a valid start value + * @param end The new end value. Must be a valid end value + */ + public final void setRange(int start, int end) { + int length = control.getLength(); + if (start < 0 || start > length || end < 0 || end > length) { + throw new IndexOutOfBoundsException(); + } + this.start = start; + this.end = end; + } + + /** + * Gets the new anchor. This value will always be > 0 and + * <= {@link #getProposedControlText()}{@code}.getLength()} + * + * @return The new anchor position + */ + public final int getNewAnchor() { return newAnchor; } + + /** + * Sets the new anchor. This value must be > 0 and + * <= {@link #getProposedControlText()}{@code}.getLength()}. Note that there + * is an order dependence here, in that the anchor should be + * specified after the new text has been specified. + * + * @param value The new anchor position + */ + public final void setNewAnchor(int value) { + if (value < 0 || value > control.getLength() - (end - start) + text.length()) { + throw new IndexOutOfBoundsException(); + } + newAnchor = value; + } + + /** + * Gets the new caret position. This value will always be > 0 and + * <= {@link #getProposedControlText()}{@code}.getLength()} + * + * @return The new caret position + */ + public final int getNewCaretPosition() { return newCaretPosition; } + + /** + * Sets the new caret position. This value must be > 0 and + * <= {@link #getProposedControlText()}{@code}.getLength()}. Note that there + * is an order dependence here, in that the caret position should be + * specified after the new text has been specified. + * + * @param value The new caret position + */ + public final void setNewCaretPosition(int value) { + if (value < 0 || value > control.getLength() - (end - start) + text.length()) { + throw new IndexOutOfBoundsException(); + } + newCaretPosition = value; + } + + /** + * Gets the text used in this change. For example, this may be new + * text being added, or text which is replacing all the control's text + * within the range of start and end. Typically it is an empty string + * only for cases where the range is being deleted. + * + * @return The text involved in this change. This will never be null. + */ + public final String getText() { return text; } + + /** + * Sets the text to use in this change. This is used to replace the + * range from start to end, if such a range exists, or to insert text + * at the position represented by start == end. + * + * @param value The text. This cannot be null. + */ + public final void setText(String value) { + if (value == null) throw new NullPointerException(); + text = value; + } + + /** + * Gets the complete new text which will be used on the control after + * this change. Note that some controls (such as TextField) may do further + * filtering after the change is made (such as stripping out newlines) + * such that you cannot assume that the newText will be exactly the same + * as what is finally set as the content on the control, however it is + * correct to assume that this is the case for the purpose of computing + * the new caret position and new anchor position (as those values supplied + * will be modified as necessary after the control has stripped any + * additional characters that the control might strip). + * + * @return The controls proposed new text at the time of this call, according + * to the state set for start, end, and text properties on this ContentChange object. + */ + public final String getProposedControlText() { + return control.getText(0, start) + text + control.getText(end, control.getLength()); + } + + /** + * Gets whether this change was in response to text being added. Note that + * after the ContentChange object is modified by the contentFilter (by one + * of the setters) the return value of this method is not altered. It answers + * as to whether this change was fired as a result of text being added, + * not whether text will end up being added in the end. + * + *

+ * Text may have been added either cause it was {@link #isInserted()}, + * {@link #isAppended()}, or {@link #isPrepended()}. + *

+ * + * @return true if text was being added + */ + public final boolean isAdded() { return added; } + + /** + * Gets whether this change was in response to text being deleted. Note that + * after the ContentChange object is modified by the contentFilter (by one + * of the setters) the return value of this method is not altered. It answers + * as to whether this change was fired as a result of text being deleted, + * not whether text will end up being deleted in the end. + * + * @return true if text was being deleted + */ + public final boolean isDeleted() { return deleted; } + + /** + * Gets whether this change was in response to text being added, and in + * particular, inserted into the midst of the control text. If this is + * true, then {@link #isAdded()} will return true. + * + * @return true if text was being inserted + */ + public final boolean isInserted() { return inserted; } + + /** + * Gets whether this change was in response to text being added, and in + * particular, appended to the end of the control text. If this is + * true, then {@link #isAdded()} will return true. + * + * @return true if text was being appended + */ + public final boolean isAppended() { return appended; } + + /** + * Gets whether this change was in response to text being added, and in + * particular, prepended at the start of the control text. If this is + * true, then {@link #isAdded()} will return true. + * + * @return true if text was being prepended + */ + public final boolean isPrepended() { return prepended; } + + /** + * Gets whether this change was in response to text being replaced. Note that + * after the ContentChange object is modified by the contentFilter (by one + * of the setters) the return value of this method is not altered. It answers + * as to whether this change was fired as a result of text being replaced, + * not whether text will end up being replaced in the end. + * + * @return true if text was being replaced + */ + public final boolean isReplaced() { return replaced; } + + @Override + public ContentChange clone() { + try { + return (ContentChange) super.clone(); + } catch (CloneNotSupportedException e) { + // Cannot happen + throw new RuntimeException(e); + } + } + } + /*************************************************************************** * * * Constructors * @@ -131,7 +530,7 @@ if (content.length() > 0) { text.textIsNull = false; } - text.invalidate(); + text.controlContentHasChanged(); }); // Bind the length to be based on the length of the text property @@ -251,7 +650,7 @@ * null if no prompt text is displayed. * @since JavaFX 2.2 */ - private StringProperty promptText = new SimpleStringProperty(this, "promptText", "") { + private final StringProperty promptText = new SimpleStringProperty(this, "promptText", "") { @Override protected void invalidated() { // Strip out newlines String txt = get(); @@ -265,6 +664,37 @@ public final String getPromptText() { return promptText.get(); } public final void setPromptText(String value) { promptText.set(value); } + /** + * The contentFilter allows the developer to intercept and modify any + * attempted change of the content of a TextInputControl. + * + * @since JavaFX 8.0u40 + */ + private final ObjectProperty contentFilter = new ObjectPropertyBase() { + @Override + public Object getBean() { + return TextInputControl.this; + } + + @Override + public String getName() { + return "contentFilter"; + } + + @Override + protected void invalidated() { + final ContentFilter filter = get(); + final Supplier defaultValueSupplier = filter.getDefaultValue(); + if (defaultValueSupplier != null && filter.getFilter().apply( + new ContentChange(TextInputControl.this, 0, getLength(), getText())) == null) { + final String val = defaultValueSupplier.get(); + replaceText(0, getLength(), val, val.length(), val.length()); + } + } + }; + public final ObjectProperty contentFilterProperty() { return contentFilter; } + public final ContentFilter getContentFilter() { return contentFilter.get(); } + public final void setContentFilter(ContentFilter value) { contentFilter.set(value); } private final Content content; /** @@ -277,7 +707,7 @@ /** * The textual content of this TextInputControl. */ - private TextProperty text = new TextProperty(); + private final TextProperty text = new TextProperty(); public final String getText() { return text.get(); } public final void setText(String value) { text.set(value); } public final StringProperty textProperty() { return text; } @@ -285,14 +715,14 @@ /** * The number of characters in the text input. */ - private ReadOnlyIntegerWrapper length = new ReadOnlyIntegerWrapper(this, "length"); + private final ReadOnlyIntegerWrapper length = new ReadOnlyIntegerWrapper(this, "length"); public final int getLength() { return length.get(); } public final ReadOnlyIntegerProperty lengthProperty() { return length.getReadOnlyProperty(); } /** * Indicates whether this TextInputControl can be edited by the user. */ - private BooleanProperty editable = new SimpleBooleanProperty(this, "editable", true) { + private final BooleanProperty editable = new SimpleBooleanProperty(this, "editable", true) { @Override protected void invalidated() { pseudoClassStateChanged(PSEUDO_CLASS_READONLY, ! get()); } @@ -304,14 +734,14 @@ /** * The current selection. */ - private ReadOnlyObjectWrapper selection = new ReadOnlyObjectWrapper(this, "selection", new IndexRange(0, 0)); + private final ReadOnlyObjectWrapper selection = new ReadOnlyObjectWrapper(this, "selection", new IndexRange(0, 0)); public final IndexRange getSelection() { return selection.getValue(); } public final ReadOnlyObjectProperty selectionProperty() { return selection.getReadOnlyProperty(); } /** * Defines the characters in the TextInputControl which are selected */ - private ReadOnlyStringWrapper selectedText = new ReadOnlyStringWrapper(this, "selectedText"); + private final ReadOnlyStringWrapper selectedText = new ReadOnlyStringWrapper(this, "selectedText"); public final String getSelectedText() { return selectedText.get(); } public final ReadOnlyStringProperty selectedTextProperty() { return selectedText.getReadOnlyProperty(); } @@ -323,7 +753,7 @@ * caretPosition. Depending on how the user selects text, * the anchor might represent the lower or upper bound of the selection. */ - private ReadOnlyIntegerWrapper anchor = new ReadOnlyIntegerWrapper(this, "anchor", 0); + private final ReadOnlyIntegerWrapper anchor = new ReadOnlyIntegerWrapper(this, "anchor", 0); public final int getAnchor() { return anchor.get(); } public final ReadOnlyIntegerProperty anchorProperty() { return anchor.getReadOnlyProperty(); } @@ -335,22 +765,21 @@ * caretPosition. Depending on how the user selects text, * the caretPosition might represent the lower or upper bound of the selection. */ - private ReadOnlyIntegerWrapper caretPosition = new ReadOnlyIntegerWrapper(this, "caretPosition", 0); + private final ReadOnlyIntegerWrapper caretPosition = new ReadOnlyIntegerWrapper(this, "caretPosition", 0); public final int getCaretPosition() { return caretPosition.get(); } public final ReadOnlyIntegerProperty caretPositionProperty() { return caretPosition.getReadOnlyProperty(); } - /** - * This flag is used to indicate that the text on replace trigger should - * NOT update the caret position. Basically it is a flag we use to - * indicate that the change to textInputControl.text was from us instead of from - * the developer. The language being what it is, it is possible that the - * developer is also bound to textInputControl.text and that they will change the - * text value before our on replace trigger gets called. We will therefore - * have to check the caret position against the text to make sure we don't - * get a caret position out of bounds. But otherwise, we don't update - * the caret when text is set internally. - */ - private boolean doNotAdjustCaret = false; + private Change undoChangeHead = new Change(); + private Change undoChange = undoChangeHead; + private boolean createNewUndoRecord = false; + + private final ReadOnlyBooleanWrapper undoable = new ReadOnlyBooleanWrapper(this, "undoable", false); + public final boolean isUndoable() { return undoable.get(); } + public final ReadOnlyBooleanProperty undoableProperty() { return undoable.getReadOnlyProperty(); } + + private final ReadOnlyBooleanWrapper redoable = new ReadOnlyBooleanWrapper(this, "redoable", false); + public final boolean isRedoable() { return redoable.get(); } + public final ReadOnlyBooleanProperty redoableProperty() { return redoable.getReadOnlyProperty(); } /*************************************************************************** * * @@ -365,7 +794,6 @@ * @param end must be less than or equal to the length */ public String getText(int start, int end) { - // TODO these checks really belong in Content if (start > end) { throw new IllegalArgumentException("The start must be <= the end"); } @@ -429,13 +857,8 @@ * @see #replaceText(int, int, String) */ public void replaceText(IndexRange range, String text) { - if (range == null) { - throw new NullPointerException(); - } - - int start = range.getStart(); - int end = start + range.getLength(); - + final int start = range.getStart(); + final int end = start + range.getLength(); replaceText(start, end, text); } @@ -463,11 +886,59 @@ } if (!this.text.isBound()) { - getContent().delete(start, end, text.isEmpty()); - getContent().insert(start, text, true); + final boolean nonEmptySelection = getSelection().getLength() > 0; + final int oldLength = getLength(); + final String oldText = getText(start, end); + int a = start + text.length(); + int cp = a; + ContentFilter filter = getContentFilter(); + if (filter != null) { + ContentChange change = new ContentChange(this, start, end, text); + change = filter.filter.apply(change); + if (change == null) { + return; + } + text = change.text; + start = change.start; + end = change.end; + a = change.newAnchor; + cp = change.newCaretPosition; + } - start += text.length(); - selectRange(start, start); + // Update the content + int adjustmentAmount = replaceText(start, end, text, a, cp); + + // If you select some stuff and type anything, then we need to + // create an undo record. If the range is a single character and + // is right next to the index of the last undo record end index, then + // we don't need to create a new undo record. In all other cases + // we do. + int endOfUndoChange = undoChange == undoChangeHead ? -1 : undoChange.start + undoChange.newText.length(); + String newText = getText(start, start + text.length() - adjustmentAmount); + if (createNewUndoRecord || nonEmptySelection || endOfUndoChange == -1 || oldLength == 0 || (endOfUndoChange != start && endOfUndoChange != end) || start - end > 1) { + undoChange = undoChange.add(start, oldText, newText); + } else if (start != end && text.isEmpty()) { + // I know I am deleting, and am located at the end of the range of the current undo record + if (undoChange.newText.length() > 0) { + undoChange.newText = undoChange.newText.substring(0, start - undoChange.start); + if (undoChange.newText.isEmpty()) { + // throw away this undo change record + undoChange = undoChange.discard(); + } + } else { + if (start == endOfUndoChange) { + undoChange.oldText += oldText; + } else { // end == endOfUndoChange + undoChange.oldText = oldText + undoChange.oldText; + undoChange.start--; + } + } + } else { + // I know I am adding, and am located at the end of the range of the current undo record + undoChange.newText += newText; + } + updateUndoRedoState(); + } } @@ -504,7 +975,12 @@ if (clipboard.hasString()) { final String text = clipboard.getString(); if (text != null) { - replaceSelection(text); + createNewUndoRecord = true; + try { + replaceSelection(text); + } finally { + createNewUndoRecord = false; + } } } } @@ -516,7 +992,7 @@ */ public void selectBackward() { if (getCaretPosition() > 0 && getLength() > 0) { - // because the anchor stays put, by moving the caret to the left + // Because the anchor stays put, by moving the caret to the left // we ensure that a selection is registered and that it is correct if (charIterator == null) { charIterator = BreakIterator.getCharacterInstance(); @@ -714,7 +1190,6 @@ * also has the effect of clearing the selection. */ public void home() { - // user wants to go to start selectRange(0, 0); } @@ -723,7 +1198,6 @@ * also has the effect of clearing the selection. */ public void end() { - // user wants to go to end final int textLength = getLength(); if (textLength > 0) { selectRange(textLength, textLength); @@ -761,7 +1235,7 @@ final int dot = getCaretPosition(); final int mark = getAnchor(); if (dot != mark) { - // there is a selection of text to remove + // There is a selection of text to remove replaceSelection(""); failed = false; } else if (dot > 0) { @@ -772,11 +1246,8 @@ // Note: Do not use charIterator here, because we do want to // break up clusters when deleting backwards. int p = Character.offsetByCodePoints(text, dot, -1); - doNotAdjustCaret = true; deleteText(p, dot); - selectRange(p, p); failed = false; - doNotAdjustCaret = false; } } return !failed; @@ -794,10 +1265,8 @@ final int dot = getCaretPosition(); final int mark = getAnchor(); if (dot != mark) { - // there is a selection of text to remove + // There is a selection of text to remove replaceSelection(""); - int newDot = Math.min(dot, mark); - selectRange(newDot, newDot); failed = false; } else if (text.length() > 0 && dot < text.length()) { // The caret is not at the end, so remove some characters. @@ -809,11 +1278,8 @@ } charIterator.setText(text); int p = charIterator.following(dot); - doNotAdjustCaret = true; - //setText(text.substring(0, dot) + text.substring(dot + delChars)); deleteText(dot, p); failed = false; - doNotAdjustCaret = false; } } return !failed; @@ -948,45 +1414,130 @@ * and the given replacement text inserted. */ public void replaceSelection(String replacement) { - if (text.isBound()) return; + replaceText(getSelection(), replacement); + } - if (replacement == null) { - throw new NullPointerException(); + /** + * If possible, undoes the last modification. If {@link #isUndoable()} returns + * false, then calling this method has no effect. + */ + public void undo() { + if (isUndoable()) { + // Apply reverse change here + final int start = undoChange.start; + final String newText = undoChange.newText; + final String oldText = undoChange.oldText; + + if (newText != null) { + getContent().delete(start, start + newText.length(), oldText.isEmpty()); + } + + if (oldText != null) { + getContent().insert(start, oldText, true); + selectRange(start, start + oldText.length()); + } else { + selectRange(start, start + newText.length()); + } + + undoChange = undoChange.prev; } + updateUndoRedoState(); + // else beep ? + } - final int dot = getCaretPosition(); - final int mark = getAnchor(); - int start = Math.min(dot, mark); - int end = Math.max(dot, mark); - int pos = dot; + /** + * If possible, redoes the last undone modification. If {@link #isRedoable()} returns + * false, then calling this method has no effect. + */ + public void redo() { + if (isRedoable()) { + // Apply change here + undoChange = undoChange.next; + final int start = undoChange.start; + final String newText = undoChange.newText; + final String oldText = undoChange.oldText; - if (getLength() == 0) { - doNotAdjustCaret = true; - setText(replacement); - selectRange(getLength(), getLength()); - doNotAdjustCaret = false; - } else { - deselect(); - // RT-16566: Need to take into account stripping of chars into caret pos - doNotAdjustCaret = true; - int oldLength = getLength(); - end = Math.min(end, oldLength); - if (end > start) { - getContent().delete(start, end, replacement.isEmpty()); - oldLength -= (end - start); + if (oldText != null) { + getContent().delete(start, start + oldText.length(), newText.isEmpty()); } - getContent().insert(start, replacement, true); - // RT-16566: Need to take into account stripping of chars into caret pos - final int p = start + getLength() - oldLength; - selectRange(p, p); - doNotAdjustCaret = false; + + if (newText != null) { + getContent().insert(start, newText, true); + selectRange(start + newText.length(), start + newText.length()); + } else { + selectRange(start, start); + } } + updateUndoRedoState(); + // else beep ? } // Used by TextArea, although there are probably other better ways of // doing this. void textUpdated() { } + private void resetUndoRedoState() { + undoChange = undoChangeHead; + undoChange.next = null; + updateUndoRedoState(); + } + + private void updateUndoRedoState() { + undoable.set(undoChange != undoChangeHead); + redoable.set(undoChange.next != null); + } + + boolean filterAndSet(String value) { + // Send the new value through the contentFilter, if one exists. + TextInputControl.ContentFilter filter = getContentFilter(); + int length = getLength(); + if (filter != null && !text.isBound()) { + TextInputControl.ContentChange change = new TextInputControl.ContentChange(TextInputControl.this, 0, length, value); + change = filter.filter.apply(change); + if (change == null) { + return false; + } + replaceText(change.start, change.end, change.text, change.newAnchor, change.newCaretPosition); + } else { + replaceText(0, length, value, value.length(), value.length()); + } + return true; + } + + /** + * This is what is ultimately called by every code path that will update + * the content (except for undo / redo). The input into this method has + * already run through the contentFilter where appropriate. + * + * @param start The start index into the existing text which + * will be replaced by the new value + * @param end The end index into the existing text which will + * be replaced by the new value. As with + * String.replace this is a lastIndex+1 value + * @param value The new text value + * @param anchor The new selection anchor after the change is made + * @param caretPosition The new selection caretPosition after the change + * is made. + * @return The amount of adjustment made to the end / anchor / caretPosition to + * accommodate for subsequent filtering (such as the filtering of + * new lines by the TextField) + */ + private int replaceText(int start, int end, String value, int anchor, int caretPosition) { + // RT-16566: Need to take into account stripping of chars into the + // final anchor & caret position + int length = getLength(); + if (end != start) { + getContent().delete(start, end, value.isEmpty()); + length -= (end - start); + } + getContent().insert(start, value, true); + final int adjustmentAmount = value.length() - (getLength() - length); + anchor -= adjustmentAmount; + caretPosition -= adjustmentAmount; + selectRange(anchor, caretPosition); + return adjustmentAmount; + } + /** * A little utility method for stripping out unwanted characters. * @@ -1042,7 +1593,7 @@ private InvalidationListener listener = null; // Used for event handling private ExpressionHelper helper = null; - // The developer my set the Text property to null. Although + // The developer may set the Text property to null. Although // the Content must be given an empty String, we must still // treat the value as though it were null, so that a subsequent // getText() will return null. @@ -1062,7 +1613,11 @@ markInvalid(); } - private void invalidate() { + /** + * Called whenever the content on the control has changed (as determined + * by a listener on the content). + */ + private void controlContentHasChanged() { markInvalid(); // accSendNotification(Attribute.TITLE); } @@ -1127,17 +1682,29 @@ fireValueChangedEvent(); } + /** + * doSet is called whenever the setText() method was called directly + * on the TextInputControl, or when the text property was bound, + * unbound, or reacted to a binding invalidation. It is *not* called + * when modifications to the content happened indirectly, such as + * through the replaceText / replaceSelection methods. + * + * @param value The new value + */ private void doSet(String value) { // Guard against the null value. textIsNull = value == null; if (value == null) value = ""; - // Update the content - content.delete(0, content.length(), value.isEmpty()); - content.insert(0, value, true); - if (!doNotAdjustCaret) { - selectRange(0, 0); - textUpdated(); - } + + if (!filterAndSet(value)) return; + + textUpdated(); + + // If the programmer has directly manipulated the text property + // or has it bound up, then we will clear out any modifications + // from the undo manager as we must suppose that the control is + // being reused, for example, between forms. + resetUndoRedoState(); } private class Listener implements InvalidationListener { @@ -1153,6 +1720,65 @@ } } + /** + * Used to form a linked-list of Undo / Redo changes. Each Change + * records the old and new text, and the start index. It also has + * the links to the previous and next Changes in the chain. There + * are two special Change objects in this chain representing the + * head and the tail so we can have beforeFirst and afterLast + * behavior as necessary. + */ + static class Change { + int start; + String oldText; + String newText; + Change prev; + Change next; + + Change() { } + + public Change add(int start, String oldText, String newText) { + Change c = new Change(); + c.start = start; + c.oldText = oldText; + c.newText = newText; + c.prev = this; + next = c; + return c; + } + + public Change discard() { + prev.next = next; + return prev; + } + + // Handy to use when debugging, just put it in undo or redo + // method or replaceText to see what is happening to the undo + // history as it occurs. + void debugPrint() { + Change c = this; + System.out.print("["); + while (c != null) { + System.out.print(c.toString()); + if (c.next != null) System.out.print(", "); + c = c.next; + } + System.out.println("]"); + } + + @Override public String toString() { + if (oldText == null && newText == null) { + return "head"; + } + if (oldText.isEmpty() && !newText.isEmpty()) { + return "added '" + newText + "' at index " + start; + } else if (!oldText.isEmpty() && !newText.isEmpty()) { + return "replaced '" + oldText + "' with '" + newText + "' at index " + start; + } else { + return "deleted '" + oldText + "' at index " + start; + } + } + } /*************************************************************************** * *