io.github.jonestimd.swing.component.MultiSelectField Maven / Gradle / Ivy
// The MIT License (MIT)
//
// Copyright (c) 2019 Timothy D. Jones
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package io.github.jonestimd.swing.component;
import java.awt.Color;
import java.awt.event.KeyEvent;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.function.BiPredicate;
import javax.swing.InputVerifier;
import javax.swing.JComponent;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
import io.github.jonestimd.swing.ComponentResources;
import io.github.jonestimd.swing.validation.Validator;
import io.github.jonestimd.util.Streams;
import static java.awt.KeyboardFocusManager.*;
/**
* A text component that displays a list of string values using {@link MultiSelectItem} and allows adding and
* removing items in the list. Items can be removed from the list using the {@code backspace} and {@code delete} keys
* or using the delete button on the {@link MultiSelectItem}s. Items can be added to the list by typing text after the
* existing items and pressing the {@code enter} key.
*/
public class MultiSelectField extends JTextPane {
public static final String ITEMS_PROPERTY = "items";
public static final BiPredicate DEFAULT_IS_VALID_ITEM = (field, text) -> !text.trim().isEmpty();
private static final Color INVALID_ITEM_BACKGROUND = ComponentResources.lookupColor("multiSelectField.invalidItem.background");
protected static final float ITEM_ALIGNMENT = 0.75f;
private final List items = new ArrayList<>();
private final boolean showItemDelete;
private final boolean opaqueItems;
private final BiPredicate isValidItem;
private final MutableAttributeSet invalidItemStyle = new SimpleAttributeSet();
private boolean yieldFocusOnError = true;
private boolean keepTextOnFocusLost = false;
/**
* Create a {@code MultiSelectField} that requires non-blank items.
* @param showItemDelete true to show delete buttons on the list items
* @param opaqueItems true to fill the list items with their background color
*/
public MultiSelectField(boolean showItemDelete, boolean opaqueItems) {
this(showItemDelete, opaqueItems, DEFAULT_IS_VALID_ITEM);
}
/**
* Create a {@code MultiSelectField}.
* @param showItemDelete true to show delete buttons on the list items
* @param opaqueItems true to fill the list items with their background color
* @param isValidItem predicate to use to validate input text before adding an item to the list
*/
public MultiSelectField(boolean showItemDelete, boolean opaqueItems, BiPredicate isValidItem) {
this.showItemDelete = showItemDelete;
this.opaqueItems = opaqueItems;
this.isValidItem = isValidItem;
addCaretListener(event -> {
int start = getSelectionStart();
int end = getSelectionEnd();
for (int i = 0; i < items.size(); i++) items.get(i).setSelected(i >= start && i < end);
});
setInputVerifier(new InputValidator());
StyleConstants.setBackground(invalidItemStyle, INVALID_ITEM_BACKGROUND);
getDocument().addDocumentListener(new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
fireItemsChanged();
}
@Override
public void removeUpdate(DocumentEvent e) {
int offset = e.getOffset();
int length = e.getLength();
if (offset < items.size()) removeItems(offset, length);
fireItemsChanged();
}
@Override
public void changedUpdate(DocumentEvent e) {
fireItemsChanged();
}
});
}
protected void fireItemsChanged() {
firePropertyChange(ITEMS_PROPERTY, null, getItems());
}
/**
* Get the AttributeSet
used to style invalid input text.
*/
public MutableAttributeSet getInvalidItemStyle() {
return invalidItemStyle;
}
/**
* Replace the list of values.
*/
public void setItems(Collection items) {
try {
getDocument().remove(0, this.items.size());
} catch (BadLocationException e) {
throw new RuntimeException(e);
}
items.forEach(this::addItem);
}
/**
* Add an item to the list of values.
*/
public void addItem(String text) {
MultiSelectItem item = newItem(text);
item.setAlignmentY(ITEM_ALIGNMENT);
item.addDeleteListener(this::removeItem);
setSelectionStart(items.size());
items.add(item);
insertComponent(item);
setSelectionStart(items.size());
}
/**
* Create a new {@link MultiSelectItem} to add to the field.
*/
protected MultiSelectItem newItem(String text) {
return new MultiSelectItem(text, showItemDelete, opaqueItems);
}
/**
* Remove an item from the list of values.
*/
protected void removeItem(MultiSelectItem item) {
int index = items.indexOf(item);
if (index >= 0) {
try {
getDocument().remove(index, 1);
items.remove(item);
} catch (BadLocationException e) {
throw new RuntimeException(e);
}
}
}
/**
* Remove a range of items. Called when items are removed from the component's Document
.
*/
private void removeItems(int start, int count) {
int lastItem = Math.min(items.size(), start + count);
items.subList(start, lastItem).clear();
}
/**
* Get the list of values, including the current input text as the last item.
*/
public List getItems() {
List items = Streams.map(this.items, MultiSelectItem::getText);
String pendingItem = getPendingItem();
if (!pendingItem.isEmpty()) items.add(pendingItem);
return items;
}
/**
* Check if the input text is a valid item.
*/
public boolean isValidItem() {
return isValidItem.test(this, getPendingItem());
}
/**
* Get the pending item text (the current input text).
*/
protected String getPendingItem() {
return getText().substring(items.size());
}
/**
* Add the current input text as an item.
*/
protected void addItem() {
try {
String text = getPendingItem();
getDocument().remove(items.size(), text.length());
addItem(text);
} catch (BadLocationException e) {
throw new RuntimeException(e);
}
}
/**
* Overridden to handle changes to the list of values.
*/
@Override
protected boolean processKeyBinding(KeyStroke ks, KeyEvent e, int condition, boolean pressed) {
if (pressed) {
int selectionStart = getSelectionStart();
if (ks.getKeyCode() == KeyEvent.VK_ENTER) {
if (isValidItem()) addItem();
return true;
}
else if (ks.getKeyCode() != KeyEvent.VK_DELETE && ks.getKeyCode() != KeyEvent.VK_BACK_SPACE
&& selectionStart < items.size() && Character.isDefined(e.getKeyChar())) {
setSelectionStart(getDocument().getLength());
}
}
if (super.processKeyBinding(ks, e, condition, pressed)) {
String text = getPendingItem();
if (!text.isEmpty()) {
AttributeSet attrs = isValidItem() ? SimpleAttributeSet.EMPTY : invalidItemStyle;
getStyledDocument().setCharacterAttributes(items.size(), text.length(), attrs, true);
}
return true;
}
return false;
}
public boolean isYieldFocusOnError() {
return yieldFocusOnError;
}
/**
* Set the policy for yielding focus when the input text is invalid.
* If false
then focus will be retained when input text is invalid.
* Defaults to true
.
*/
public void setYieldFocusOnError(boolean yieldFocusOnError) {
this.yieldFocusOnError = yieldFocusOnError;
}
public boolean isKeepTextOnFocusLost() {
return keepTextOnFocusLost;
}
/**
* Set the policy for handling input text when focus is lost.
* If false
then valid input text will be added as an item and invalid input text will be cleared.
* Defaults to false
.
*/
public void setKeepTextOnFocusLost(boolean keepTextOnFocusLost) {
this.keepTextOnFocusLost = keepTextOnFocusLost;
}
protected class InputValidator extends InputVerifier {
@Override
public boolean verify(JComponent input) {
return getText().length() == items.size() || isValidItem();
}
@Override
public boolean shouldYieldFocus(JComponent input) {
try {
if (super.shouldYieldFocus(input)) {
if (!isKeepTextOnFocusLost() && getText().length() > items.size()) addItem();
return true;
}
if (isYieldFocusOnError() && !isKeepTextOnFocusLost()) {
int itemCount = items.size();
getDocument().remove(itemCount, getText().length() - itemCount);
}
return isYieldFocusOnError();
} catch (BadLocationException ex) {
return true;
}
}
}
public static Builder builder(boolean showDelete, boolean opaqueItems) {
return new Builder<>(showDelete, opaqueItems, MultiSelectField::new);
}
/**
* Helper class for building a {@link MultiSelectField}.
*/
public static class Builder {
private final boolean showDelete;
private final boolean opaqueItems;
private BiPredicate isValidItem = DEFAULT_IS_VALID_ITEM;
private Collection items;
private boolean disableTab;
private boolean yieldFocusOnError = true;
private boolean keepTextOnFocusLost;
private Constructor constructor;
/**
* @see MultiSelectField#MultiSelectField(boolean, boolean)
*/
protected Builder(boolean showDelete, boolean opaqueItems, Constructor constructor) {
this.constructor = constructor;
this.showDelete = showDelete;
this.opaqueItems = opaqueItems;
}
protected Builder(Builder> source, Constructor constructor) {
this.showDelete = source.showDelete;
this.opaqueItems = source.opaqueItems;
this.isValidItem = source.isValidItem;
this.items = source.items;
this.disableTab = source.disableTab;
this.yieldFocusOnError = source.yieldFocusOnError;
this.keepTextOnFocusLost = source.keepTextOnFocusLost;
this.constructor = constructor;
}
/**
* Set the pending item validator.
*/
public Builder pendingItemValidator(BiPredicate isValidItem) {
this.isValidItem = isValidItem;
return this;
}
/**
* Initialize the list of values in the field.
*/
public Builder items(Collection items) {
this.items = items;
return this;
}
/**
* Set the item list validator. Creates a {@link ValidatedMultiSelectField}.
*/
public Builder validator(Validator> validator) {
Constructor constructor = (showDelete, opaqueItems, isValidItem) -> {
ValidatedMultiSelectField validatedField = new ValidatedMultiSelectField(showDelete, opaqueItems, isValidItem);
validatedField.setValidator(validator);
return validatedField;
};
return new Builder<>(this, constructor);
}
/**
* Disable tab
as text input and enable focus traversal using tab
and shift tab
.
*/
public Builder disableTab() {
this.disableTab = true;
return this;
}
public Builder setYieldFocusOnError(boolean yieldFocusOnError) {
this.yieldFocusOnError = yieldFocusOnError;
return this;
}
public Builder setKeepTextOnFocusLost(boolean keepTextOnFocusLost) {
this.keepTextOnFocusLost = keepTextOnFocusLost;
return this;
}
/**
* @return a {@link MultiSelectField} or {@link ValidatedMultiSelectField}
*/
public T get() {
final T field = constructor.get(showDelete, opaqueItems, isValidItem);
if (items != null) field.setItems(items);
if (disableTab) {
field.getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0), "disable-insert-tab");
field.setFocusTraversalKeys(FORWARD_TRAVERSAL_KEYS, Collections.singleton(KeyStroke.getKeyStroke("pressed TAB")));
field.setFocusTraversalKeys(BACKWARD_TRAVERSAL_KEYS, Collections.singleton(KeyStroke.getKeyStroke("shift pressed TAB")));
}
field.setYieldFocusOnError(yieldFocusOnError);
field.setKeepTextOnFocusLost(keepTextOnFocusLost);
return field;
}
}
protected interface Constructor {
T get(boolean showDelete, boolean opaqueItems, BiPredicate isValidItem);
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy