public class SearchableComboBox< T > extends ComboBox< T > {
private static final String DEFAULT_STYLE_CLASS = "searchable-combo-box";
// Cache the Skin so it can be referenced by SpreadsheetView contexts.
protected SearchableComboBoxSkin< T > searchableComboBoxSkin;
public SearchableComboBox() {
this(FXCollections.observableArrayList());
}
public SearchableComboBox( final ObservableList< T > items ) {
super( items );
getStyleClass().add(DEFAULT_STYLE_CLASS);
}
@Override
protected Skin< ? > createDefaultSkin() {
searchableComboBoxSkin = new SearchableComboBoxSkin<>( this );
return searchableComboBoxSkin;
}
public SearchableComboBoxSkin< T > getDefaultSkin() {
return searchableComboBoxSkin;
}
}
public static class ListEditor< R > extends SpreadsheetCellEditor {
protected static final boolean isCategoryLabel( final String item ) {
// NOTE: We must maintain specific formatting for lines that are
// markers for categories vs. actual supported items for selection,
// as a simple strategy for now vs. an explicit exclusion list that
// is pre-generated.
final boolean isCategoryLabel = item.startsWith( "--" ) || item.endsWith( ":" );
return isCategoryLabel;
}
private final List< String > itemList;
// We need to know at all times whether we are marked as Searchable.
protected boolean searchable;
protected final ComboBox< String > cb;
protected String originalValue;
private ChangeListener< String > changeListener;
/**
* This is needed because "endEdit" will call our "end" method too late
* when pressing ENTER, so several "endEdit" calls will occur. This flag
* is designed to prevent that from happening.
*/
protected boolean ending;
/**
* Constructor for the ListEditor.
*
* @param view
* The SpreadsheetView host for this ListEditor
* @param pItemList
* The items to display in the editor
* @param pSearchable
* Flag for whether to use a SearchableComboBox or a
* regular ComboBox as the selector/editor
*/
public ListEditor( final SpreadsheetView view,
final List< String > pItemList,
final boolean pSearchable ) {
super( view );
ending = false;
itemList = pItemList;
searchable = pSearchable;
final ObservableList< String > items = FXCollections.observableList( itemList );
cb = searchable ? new SearchableComboBox<>( items ) : new ComboBox<>( items );
cb.setVisibleRowCount( 16 );
// The items list is static as we regenerate the ComboBox every
// time the list changes, so we immediately disable the custom
// syntax based category labels.
cb.setCellFactory( param -> {
final ListCell< String > listCell = new ListCell< String >() {
@Override
public void updateItem( final String item, final boolean empty ) {
super.updateItem( item, empty );
if ( item != null ) {
setText( item );
// Selectively enable or disable each item based on
// whether it is a Category Label or a real item.
final boolean disable = isCategoryLabel( item );
setDisable( disable );
if ( disable ) {
setTextFill( Color.DARKGRAY );
}
else {
setTextFill( Color.BLACK );
}
}
else {
setText( null );
}
}
};
return listCell;
} );
}
/***************************************************************************
* * Public Methods * *
**************************************************************************/
/** {@inheritDoc} */
@Override
public void startEdit( final Object value, final String format, final Object... options ) {
if ( value instanceof String ) {
originalValue = value.toString();
}
else {
originalValue = null;
}
final ObservableList< String > items = FXCollections.observableList( itemList );
cb.setItems( items );
cb.setValue( originalValue );
attachEnterEscapeEventHandler();
cb.show();
// Do not request focus if we are using a SearchableComboBox as
// that prevents the specialized custom control from showing on the
// screen due to some quirks in the implementation.
if ( !searchable ) {
cb.requestFocus();
}
}
/** {@inheritDoc} */
@Override
public void end() {
cb.setOnKeyPressed( null );
if ( changeListener != null ) {
cb.valueProperty().removeListener( changeListener );
}
}
/** {@inheritDoc} */
@Override
public ComboBox< String > getEditor() {
return cb;
}
/** {@inheritDoc} */
@Override
public String getControlValue() {
return cb.getSelectionModel().getSelectedItem();
}
/** {@inheritDoc} */
@Override
public int getSelectedIndex() {
return cb.getSelectionModel().getSelectedIndex();
}
/***************************************************************************
* * Private Methods * *
**************************************************************************/
private void attachEnterEscapeEventHandler() {
// To avoid deadlock, we first release any listeners already
// attached.
if ( changeListener != null ) {
cb.valueProperty().removeListener( changeListener );
}
// We need to set an "ending" flag because otherwise the change
// listener may auto-save when "ENTER" or "ESCAPE" is pressed,
// before we get a chance to add our special handling rules.
cb.setOnKeyPressed( keyEvent -> {
final KeyCode keyCode = keyEvent.getCode();
switch ( keyCode ) {
case ENTER:
ending = true;
endEdit( true );
ending = false;
break;
case ESCAPE:
cb.setValue( originalValue );
endEdit( false );
break;
// $CASES-OMITTED$
default:
break;
}
} );
// Unless the ComboBox is processing an ENTER or ESCAPE key event,
/ we should immediately commit edits from list selection changes.
// NOTE: An exception is made when we are Searchable, as that is a
// multi-level control just like other complex controls such as the
// Color Picker, with several levels of focus and commit handling.
f ( !searchable ) {
changeListener = ( observableValue, oldValue, newValue ) -> {
if ( !ending ) {
endEdit( true );
}
};
cb.valueProperty().addListener( changeListener );
}
else {
// If we are using a SearchableComboBox, we have to instead wait
// for focus to be lost from the search field, as that is what
// indicates that the user has either made a new selection or
// canceled changes to restore the old choice.
final SearchableComboBoxSkin< String > skin =
( ( SearchableComboBox< String > ) cb )
.getDefaultSkin();
final CustomTextField searchField = skin.getSearchField();
searchField.focusedProperty()
.addListener( ( observableValue, oldValue, newValue ) -> {
// If we just lost focus on the search field, the
// user has either made their new choice or canceled
// changes to restore the old choice.
if ( !newValue ) {
// As with regular ComboBox user interaction, we
// have to be careful to avoid double-processing
// on ENTER key handling, but probably this is
// unreachable code due to order of handling.
if ( !ending ) {
endEdit( true );
}
}
} );
}
}
}
public CustomTextField getSearchField() {
return searchField;
}