AbstractComboModel.java
package swingtree;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import sprouts.From;
import sprouts.Var;
import javax.swing.ComboBoxModel;
import javax.swing.event.ListDataEvent;
import javax.swing.event.ListDataListener;
import java.util.ArrayList;
import java.util.Locale;
import java.util.Objects;
/**
* A {@link ComboBoxModel} type designed in a way to allow for MVVM style
* property binding to the selection state of the model.
* This model wraps a {@link sprouts.Var} instance which is used
* to dynamically model the selection state of the model.
*
* @param <E> The type of the elements which will be stored in this model.
*/
abstract class AbstractComboModel<E extends @Nullable Object> implements ComboBoxModel<E>
{
private static final Logger log = org.slf4j.LoggerFactory.getLogger(AbstractComboModel.class);
protected int _selectedIndex = -1;
final Var<E> _selectedItem;
protected java.util.List<ListDataListener> listeners = new ArrayList<>();
private boolean _acceptsEditorChanges = true; // This is important to prevent getting feedback loops!
protected static <E> Class<E> _findCommonType( E[] items ) {
Iterable<E> iterable = () -> java.util.Arrays.stream(items).iterator();
return _findCommonType(iterable);
}
protected static <E> Class<E> _findCommonType( Iterable<E> items ) {
if ( items == null ) return (Class<E>) Object.class;
Class<E> type = null;
for ( E item : items ) {
if ( item == null ) continue;
if ( type == null )
type = (Class<E>) item.getClass();
else
type = (Class<E>) Object.class;
}
if ( type == null )
type = (Class<E>) Object.class;
return type;
}
AbstractComboModel( Var<E> selectedItem ) {
_selectedItem = Objects.requireNonNull(selectedItem);
}
final boolean acceptsEditorChanges() {
return _acceptsEditorChanges;
}
final Var<E> _getSelectedItemVar() { return _selectedItem; }
abstract AbstractComboModel<E> withVar( Var<E> newVar );
@SuppressWarnings("NullAway")
@Override public void setSelectedItem( @Nullable Object anItem ) {
if ( anItem != null && !_selectedItem.type().isAssignableFrom(anItem.getClass()) )
anItem = _convert(anItem.toString());
E old = _selectedItem.orElseNull();
Object finalAnItem = anItem;
doQuietly(()-> {
_selectedItem.set(From.VIEW, (E) NullUtil.fakeNonNull(finalAnItem));
_selectedIndex = _indexOf(finalAnItem);
if ( !Objects.equals(old, finalAnItem) )
fireListeners();
});
}
/** {@inheritDoc} */
@Override public @Nullable Object getSelectedItem() { return _selectedItem.orElseNull(); }
/** {@inheritDoc} */
@Override public void addListDataListener( ListDataListener l ) { listeners.add(l); }
/** {@inheritDoc} */
@Override public void removeListDataListener( ListDataListener l ) { listeners.remove(l); }
void fireListeners() {
try {
for ( ListDataListener l : new ArrayList<>(listeners) )
l.contentsChanged(
new ListDataEvent(this, ListDataEvent.CONTENTS_CHANGED, 0, getSize())
);
} catch ( Exception e ) {
log.error("An exception occurred while firing combo box model listeners!", e);
}
}
void doQuietly( Runnable task ) {
boolean alreadyWithinQuietTask = !_acceptsEditorChanges;
_acceptsEditorChanges = false;
try {
task.run();
} catch ( Exception e ) {
log.error("An exception occurred while running a combo box model task!", e);
}
if ( !alreadyWithinQuietTask )
_acceptsEditorChanges = true;
}
abstract protected void setAt( int index, @Nullable E element );
void updateSelectedIndex() {
if ( _selectedIndex >= 0 && !_selectedItem.is(getElementAt(_selectedIndex)) )
_selectedIndex = _indexOf(_selectedItem.orElseNull());
}
void setFromEditor( String o ) {
if ( !_acceptsEditorChanges )
return; // The editor of a combo box can have very strange behaviour when it is updated by listeners
updateSelectedIndex();
if ( _selectedIndex != -1 ) {
try {
E e = _convert(o);
this.setAt( _selectedIndex, e );
boolean stateChanged = _selectedItem.orElseNull() != e;
_selectedItem.set(From.VIEW, NullUtil.fakeNonNull(e));
if ( stateChanged )
doQuietly(this::fireListeners);
} catch (Exception ignored) {
// It looks like conversion was not successful
// So this means the editor input could not be converted to the type of the combo box
// So we'll just ignore it
}
}
}
/**
* Tries to convert the given {@link String} to the type of the combo box
* through a number of different ways.
* @param o The string to convert
* @return The converted object or simply the item of the combo box if no conversion was possible.
*/
private @Nullable E _convert( String o ) {
// We need to turn the above string into an object of the correct type!
// First of all, we know our target type:
Class<E> type = _selectedItem.type();
// Now we need to convert it to that type, let's try a few things:
if ( type == Object.class )
return (E) o; // So apparently the type is intended to be Object, so we'll just return the string
if ( type == String.class ) // The most elegant case, the type is String, so we'll just return the string
return (E) o;
if ( Number.class.isAssignableFrom(type) ) {
// Ah, a number, let's try to parse it, but first we make it easier.
o = o.trim();
if ( o.endsWith("f") || o.endsWith("F") )
o = o.substring(0, o.length() - 1);
if ( o.endsWith("d") || o.endsWith("D") )
o = o.substring(0, o.length() - 1);
if ( o.endsWith("l") || o.endsWith("L") )
o = o.substring(0, o.length() - 1);
try {
if ( type == Integer.class ) return (E) Integer.valueOf(o);
if ( type == Double.class ) return (E) Double.valueOf(o);
if ( type == Float.class ) return (E) Float.valueOf(o);
if ( type == Long.class ) return (E) Long.valueOf(o);
if ( type == Short.class ) return (E) Short.valueOf(o);
if ( type == Byte.class ) return (E) Byte.valueOf(o);
} catch ( NumberFormatException e ) {
// We failed to parse the number... the input is invalid!
// So we cannot update the model, and simply return the old value:
return _selectedItem.orElseNull();
}
}
// What now? Hmmm, let's try Boolean!
if ( type == Boolean.class ) {
o = o.trim().toLowerCase(Locale.ENGLISH);
if ( o.equals("true") || o.equals("yes") || o.equals("1") )
return type.cast(Boolean.TRUE);
if ( o.equals("false") || o.equals("no") || o.equals("0") )
return type.cast(Boolean.FALSE);
// We failed to parse the boolean... the input is invalid!
// So we cannot update the model, and simply return the old value:
return _selectedItem.orElseNull();
}
// Ok maybe it's an enum?
if ( type.isEnum() ) {
Class<? extends Enum> enumType = type.asSubclass(Enum.class);
String name = o.trim();
try {
return type.cast(Enum.valueOf(enumType, name));
} catch ( IllegalArgumentException ignored) {
log.debug("Failed to parse enum string '"+name+"' as "+type+".", ignored);
}
name = o.toUpperCase(Locale.ENGLISH);
try {
return type.cast(Enum.valueOf(enumType, name));
} catch ( IllegalArgumentException ignored) {
log.debug("Failed to parse enum string '"+name+"' as "+type+".", ignored);
}
name = o.toLowerCase(Locale.ENGLISH);
try {
return type.cast(Enum.valueOf(enumType, name));
} catch ( IllegalArgumentException ignored) {
log.debug("Failed to parse enum string '"+name+"' as "+type+".", ignored);
}
name = name.toUpperCase(Locale.ENGLISH).replace(' ', '_').replace('-', '_');
try {
return type.cast(Enum.valueOf(enumType, name));
} catch ( IllegalArgumentException ignored) {
log.debug("Failed to parse enum string '"+name+"' as "+type+".", ignored);
}
name = name.toLowerCase(Locale.ENGLISH).replace(' ', '_').replace('-', '_');
try {
return type.cast(Enum.valueOf(enumType, name));
} catch ( IllegalArgumentException ignored) {
log.debug("Failed to parse enum string '"+name+"' as "+type+".", ignored);
}
// We failed to parse the enum... the input is invalid!
// So we cannot update the model, and simply return the old value:
return _selectedItem.orElseNull();
}
// Or a character?
if ( type == Character.class ) {
if ( o.trim().length() == 1 )
return type.cast(o.charAt(0));
// Maybe it's all repeated?
if ( o.trim().length() > 1 ) {
char c = o.charAt(0);
for ( int i = 1; i < o.length(); i++ )
if ( o.charAt(i) != c )
return _selectedItem.orElseNull();
return type.cast(c);
}
// We failed to parse the character... the input is invalid!
// So we cannot update the model, and simply return the old value:
return _selectedItem.orElseNull();
}
// Now it's getting tricky, but we don't give up. How about arrays?
if ( type.isArray() ) {
// We need to split the string into elements, and then convert each element
// to the correct type. We can do this recursively, but first we need to
// find the type of the elements:
Class<?> componentType = type.getComponentType();
// Now we can split the string:
String[] parts = o.split(",");
if ( parts.length == 1 )
parts = o.split(" ");
// And convert each part to the correct type:
Object[] array = (Object[]) java.lang.reflect.Array.newInstance(componentType, parts.length);
for ( int i = 0; i < parts.length; i++ )
array[i] = _convert(parts[i]);
// And finally we can return the array:
return type.cast(array);
}
// Uff! Still nothing?!? Ok let's be creative, maybe we can try to use the constructor:
try {
return type.getConstructor(String.class).newInstance(o);
} catch ( Exception e ) {
// We failed to instantiate the class... Quite a pity, but at this point, who cares?
}
// What else is there? We don't know, so we just return the old value:
return _selectedItem.orElseNull();
}
protected int _indexOf( @Nullable Object anItem ) {
for ( int i = 0; i < getSize(); i++ )
if ( Objects.equals(anItem, getElementAt(i)) )
return i;
return _selectedIndex;
}
}