/* * This file is part of lanterna (http://code.google.com/p/lanterna/). * * lanterna is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * * Copyright (C) 2010-2017 Martin Berglund */ package com.googlecode.lanterna.gui2; import com.googlecode.lanterna.*; import com.googlecode.lanterna.graphics.Theme; import com.googlecode.lanterna.graphics.ThemeDefinition; import com.googlecode.lanterna.input.KeyStroke; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * This is a simple combo box implementation that allows the user to select one out of multiple items through a * drop-down menu. If the combo box is not in read-only mode, the user can also enter free text in the combo box, much * like a {@code TextBox}. * @param <V> Type to use for the items in the combo box * @author Martin */ public class ComboBox<V> extends AbstractInteractableComponent<ComboBox<V>> { /** * Listener interface that can be used to catch user events on the combo box */ public interface Listener { /** * This method is called whenever the user changes selection from one item to another in the combo box * @param selectedIndex Index of the item which is now selected * @param previousSelection Index of the item which was previously selected */ void onSelectionChanged(int selectedIndex, int previousSelection); } private final List<V> items; private final List<Listener> listeners; private PopupWindow popupWindow; private String text; private int selectedIndex; private boolean readOnly; private boolean dropDownFocused; private int textInputPosition; /** * Creates a new {@code ComboBox} initialized with N number of items supplied through the varargs parameter. If at * least one item is given, the first one in the array will be initially selected * @param items Items to populate the new combo box with */ public ComboBox(V... items) { this(Arrays.asList(items)); } /** * Creates a new {@code ComboBox} initialized with N number of items supplied through the items parameter. If at * least one item is given, the first one in the collection will be initially selected * @param items Items to populate the new combo box with */ public ComboBox(Collection<V> items) { this(items, items.isEmpty() ? -1 : 0); } /** * Creates a new {@code ComboBox} initialized with N number of items supplied through the items parameter. The * initial text in the combo box is set to a specific value passed in through the {@code initialText} parameter, it * can be a text which is not contained within the items and the selection state of the combo box will be * "no selection" (so {@code getSelectedIndex()} will return -1) until the user interacts with the combo box and * manually changes it * * @param initialText Text to put in the combo box initially * @param items Items to populate the new combo box with */ public ComboBox(String initialText, Collection<V> items) { this(items, -1); this.text = initialText; } /** * Creates a new {@code ComboBox} initialized with N number of items supplied through the items parameter. The * initially selected item is specified through the {@code selectedIndex} parameter. * @param items Items to populate the new combo box with * @param selectedIndex Index of the item which should be initially selected */ public ComboBox(Collection<V> items, int selectedIndex) { for(V item: items) { if(item == null) { throw new IllegalArgumentException("Cannot add null elements to a ComboBox"); } } this.items = new ArrayList<V>(items); this.listeners = new CopyOnWriteArrayList<Listener>(); this.popupWindow = null; this.selectedIndex = selectedIndex; this.readOnly = true; this.dropDownFocused = true; this.textInputPosition = 0; if(selectedIndex != -1) { this.text = this.items.get(selectedIndex).toString(); } else { this.text = ""; } } /** * Adds a new item to the combo box, at the end * @param item Item to add to the combo box * @return Itself */ public synchronized ComboBox<V> addItem(V item) { if(item == null) { throw new IllegalArgumentException("Cannot add null elements to a ComboBox"); } items.add(item); if(selectedIndex == -1 && items.size() == 1) { setSelectedIndex(0); } invalidate(); return this; } /** * Adds a new item to the combo box, at a specific index * @param index Index to add the item at * @param item Item to add * @return Itself */ public synchronized ComboBox<V> addItem(int index, V item) { if(item == null) { throw new IllegalArgumentException("Cannot add null elements to a ComboBox"); } items.add(index, item); if(index <= selectedIndex) { setSelectedIndex(selectedIndex + 1); } invalidate(); return this; } /** * Removes all items from the combo box * @return Itself */ public synchronized ComboBox<V> clearItems() { items.clear(); setSelectedIndex(-1); invalidate(); return this; } /** * Removes a particular item from the combo box, if it is present, otherwise does nothing * @param item Item to remove from the combo box * @return Itself */ public synchronized ComboBox<V> removeItem(V item) { int index = items.indexOf(item); if(index == -1) { return this; } return remoteItem(index); } /** * Removes an item from the combo box at a particular index * @param index Index of the item to remove * @return Itself * @throws IndexOutOfBoundsException if the index is out of range */ public synchronized ComboBox<V> remoteItem(int index) { items.remove(index); if(index < selectedIndex) { setSelectedIndex(selectedIndex - 1); } else if(index == selectedIndex) { setSelectedIndex(-1); } invalidate(); return this; } /** * Updates the combo box so the item at the specified index is swapped out with the supplied value in the * {@code item} parameter * @param index Index of the item to swap out * @param item Item to replace with * @return Itself */ public synchronized ComboBox<V> setItem(int index, V item) { if(item == null) { throw new IllegalArgumentException("Cannot add null elements to a ComboBox"); } items.set(index, item); invalidate(); return this; } /** * Counts and returns the number of items in this combo box * @return Number of items in this combo box */ public synchronized int getItemCount() { return items.size(); } /** * Returns the item at the specific index * @param index Index of the item to return * @return Item at the specific index * @throws IndexOutOfBoundsException if the index is out of range */ public synchronized V getItem(int index) { return items.get(index); } /** * Returns the text currently displayed in the combo box, this will likely be the label of the selected item but for * writable combo boxes it's also what the user has typed in * @return String currently displayed in the combo box */ public String getText() { return text; } /** * Sets the combo box to either read-only or writable. In read-only mode, the user cannot type in any text in the * combo box but is forced to pick one of the items, displayed by the drop-down. In writable mode, the user can * enter any string in the combo box * @param readOnly If the combo box should be in read-only mode, pass in {@code true}, otherwise {@code false} for * writable mode * @return Itself */ public synchronized ComboBox<V> setReadOnly(boolean readOnly) { this.readOnly = readOnly; if(readOnly) { dropDownFocused = true; } return this; } /** * Returns {@code true} if this combo box is in read-only mode * @return {@code true} if this combo box is in read-only mode, {@code false} otherwise */ public boolean isReadOnly() { return readOnly; } /** * Returns {@code true} if the users input focus is currently on the drop-down button of the combo box, so that * pressing enter would trigger the popup window. This is generally used by renderers only and is always true for * read-only combo boxes as the component won't allow you to focus on the text in that mode. * @return {@code true} if the input focus is on the drop-down "button" of the combo box */ public boolean isDropDownFocused() { return dropDownFocused || isReadOnly(); } /** * For writable combo boxes, this method returns the position where the text input cursor is right now. Meaning, if * the user types some character, where are those are going to be inserted in the string that is currently * displayed. If the text input position equals the size of the currently displayed text, new characters will be * appended at the end. The user can usually move the text input position by using left and right arrow keys on the * keyboard. * @return Current text input position */ public int getTextInputPosition() { return textInputPosition; } /** * Programmatically selects one item in the combo box, which causes the displayed text to change to match the label * of the selected index * @param selectedIndex Index of the item to select * @throws IndexOutOfBoundsException if the index is out of range */ public synchronized void setSelectedIndex(final int selectedIndex) { if(items.size() <= selectedIndex || selectedIndex < -1) { throw new IndexOutOfBoundsException("Illegal argument to ComboBox.setSelectedIndex: " + selectedIndex); } final int oldSelection = this.selectedIndex; this.selectedIndex = selectedIndex; if(selectedIndex == -1) { text = ""; } else { text = items.get(selectedIndex).toString(); } if(textInputPosition > text.length()) { textInputPosition = text.length(); } runOnGUIThreadIfExistsOtherwiseRunDirect(new Runnable() { @Override public void run() { for(Listener listener: listeners) { listener.onSelectionChanged(selectedIndex, oldSelection); } } }); invalidate(); } /** * Returns the index of the currently selected item * @return Index of the currently selected item */ public int getSelectedIndex() { return selectedIndex; } /** * Returns the item at the selected index, this is the same as calling: * <pre> * comboBox.getItem(comboBox.getSelectedIndex()); * </pre> * @return The item at the selected index */ public synchronized V getSelectedItem() { return getItem(getSelectedIndex()); } /** * Adds a new listener to the {@code ComboBox} that will be called on certain user actions * @param listener Listener to attach to this {@code ComboBox} * @return Itself */ public ComboBox<V> addListener(Listener listener) { if(listener != null && !listeners.contains(listener)) { listeners.add(listener); } return this; } /** * Removes a listener from this {@code ComboBox} so that if it had been added earlier, it will no longer be * called on user actions * @param listener Listener to remove from this {@code ComboBox} * @return Itself */ public ComboBox<V> removeListener(Listener listener) { listeners.remove(listener); return this; } @Override protected void afterEnterFocus(FocusChangeDirection direction, Interactable previouslyInFocus) { if(direction == FocusChangeDirection.RIGHT && !isReadOnly()) { dropDownFocused = false; selectedIndex = 0; } } @Override protected synchronized void afterLeaveFocus(FocusChangeDirection direction, Interactable nextInFocus) { if(popupWindow != null) { popupWindow.close(); popupWindow = null; } } @Override protected InteractableRenderer<ComboBox<V>> createDefaultRenderer() { return new DefaultComboBoxRenderer<V>(); } @Override public synchronized Result handleKeyStroke(KeyStroke keyStroke) { if(isReadOnly()) { return handleReadOnlyCBKeyStroke(keyStroke); } else { return handleEditableCBKeyStroke(keyStroke); } } private Result handleReadOnlyCBKeyStroke(KeyStroke keyStroke) { switch(keyStroke.getKeyType()) { case ArrowDown: if(popupWindow != null) { popupWindow.listBox.handleKeyStroke(keyStroke); return Result.HANDLED; } return Result.MOVE_FOCUS_DOWN; case ArrowUp: if(popupWindow != null) { popupWindow.listBox.handleKeyStroke(keyStroke); return Result.HANDLED; } return Result.MOVE_FOCUS_UP; case Enter: if(popupWindow != null) { popupWindow.listBox.handleKeyStroke(keyStroke); popupWindow.close(); popupWindow = null; } else { popupWindow = new PopupWindow(); popupWindow.setPosition(toGlobal(new TerminalPosition(0, 1))); ((WindowBasedTextGUI) getTextGUI()).addWindow(popupWindow); } break; case Escape: if(popupWindow != null) { popupWindow.close(); popupWindow = null; return Result.HANDLED; } break; default: } return super.handleKeyStroke(keyStroke); } private Result handleEditableCBKeyStroke(KeyStroke keyStroke) { //First check if we are in drop-down focused mode, treat keystrokes a bit differently then if(isDropDownFocused()) { switch(keyStroke.getKeyType()) { case ReverseTab: case ArrowLeft: dropDownFocused = false; textInputPosition = text.length(); return Result.HANDLED; //The rest we can process in the same way as with read-only combo boxes when we are in drop-down focused mode default: return handleReadOnlyCBKeyStroke(keyStroke); } } switch(keyStroke.getKeyType()) { case Character: text = text.substring(0, textInputPosition) + keyStroke.getCharacter() + text.substring(textInputPosition); textInputPosition++; return Result.HANDLED; case Tab: dropDownFocused = true; return Result.HANDLED; case Backspace: if(textInputPosition > 0) { text = text.substring(0, textInputPosition - 1) + text.substring(textInputPosition); textInputPosition--; } return Result.HANDLED; case Delete: if(textInputPosition < text.length()) { text = text.substring(0, textInputPosition) + text.substring(textInputPosition + 1); } return Result.HANDLED; case ArrowLeft: if(textInputPosition > 0) { textInputPosition--; } else { return Result.MOVE_FOCUS_LEFT; } return Result.HANDLED; case ArrowRight: if(textInputPosition < text.length()) { textInputPosition++; } else { dropDownFocused = true; return Result.HANDLED; } return Result.HANDLED; case ArrowDown: if(selectedIndex < items.size() - 1) { setSelectedIndex(selectedIndex + 1); } return Result.HANDLED; case ArrowUp: if(selectedIndex > 0) { setSelectedIndex(selectedIndex - 1); } return Result.HANDLED; default: } return super.handleKeyStroke(keyStroke); } private class PopupWindow extends BasicWindow { private final ActionListBox listBox; public PopupWindow() { setHints(Arrays.asList( Hint.NO_FOCUS, Hint.FIXED_POSITION)); listBox = new ActionListBox(ComboBox.this.getSize().withRows(getItemCount())); for(int i = 0; i < getItemCount(); i++) { V item = items.get(i); final int index = i; listBox.addItem(item.toString(), new Runnable() { @Override public void run() { setSelectedIndex(index); close(); } }); } listBox.setSelectedIndex(getSelectedIndex()); setComponent(listBox); } @Override public synchronized Theme getTheme() { return ComboBox.this.getTheme(); } } /** * Helper interface that doesn't add any new methods but makes coding new combo box renderers a little bit more clear */ public static abstract class ComboBoxRenderer<V> implements InteractableRenderer<ComboBox<V>> { } /** * This class is the default renderer implementation which will be used unless overridden. The combo box is rendered * like a text box with an arrow point down to the right of it, which can receive focus and triggers the popup. * @param <V> Type of items in the combo box */ public static class DefaultComboBoxRenderer<V> extends ComboBoxRenderer<V> { private int textVisibleLeftPosition; /** * Default constructor */ public DefaultComboBoxRenderer() { this.textVisibleLeftPosition = 0; } @Override public TerminalPosition getCursorLocation(ComboBox<V> comboBox) { if(comboBox.isDropDownFocused()) { if(comboBox.getThemeDefinition().isCursorVisible()) { return new TerminalPosition(comboBox.getSize().getColumns() - 1, 0); } else { return null; } } else { int textInputPosition = comboBox.getTextInputPosition(); int textInputColumn = TerminalTextUtils.getColumnWidth(comboBox.getText().substring(0, textInputPosition)); return new TerminalPosition(textInputColumn - textVisibleLeftPosition, 0); } } @Override public TerminalSize getPreferredSize(final ComboBox<V> comboBox) { TerminalSize size = TerminalSize.ONE.withColumns( (comboBox.getItemCount() == 0 ? TerminalTextUtils.getColumnWidth(comboBox.getText()) : 0) + 2); //noinspection SynchronizationOnLocalVariableOrMethodParameter synchronized(comboBox) { for(int i = 0; i < comboBox.getItemCount(); i++) { V item = comboBox.getItem(i); size = size.max(new TerminalSize(TerminalTextUtils.getColumnWidth(item.toString()) + 2 + 1, 1)); // +1 to add a single column of space } } return size; } @Override public void drawComponent(TextGUIGraphics graphics, ComboBox<V> comboBox) { ThemeDefinition themeDefinition = comboBox.getThemeDefinition(); if(comboBox.isReadOnly()) { graphics.applyThemeStyle(themeDefinition.getNormal()); } else { if(comboBox.isFocused()) { graphics.applyThemeStyle(themeDefinition.getActive()); } else { graphics.applyThemeStyle(themeDefinition.getPreLight()); } } graphics.fill(' '); int editableArea = graphics.getSize().getColumns() - 2; //This is exclusing the 'drop-down arrow' int textInputPosition = comboBox.getTextInputPosition(); int columnsToInputPosition = TerminalTextUtils.getColumnWidth(comboBox.getText().substring(0, textInputPosition)); if(columnsToInputPosition < textVisibleLeftPosition) { textVisibleLeftPosition = columnsToInputPosition; } if(columnsToInputPosition - textVisibleLeftPosition >= editableArea) { textVisibleLeftPosition = columnsToInputPosition - editableArea + 1; } if(columnsToInputPosition - textVisibleLeftPosition + 1 == editableArea && comboBox.getText().length() > textInputPosition && TerminalTextUtils.isCharCJK(comboBox.getText().charAt(textInputPosition))) { textVisibleLeftPosition++; } String textToDraw = TerminalTextUtils.fitString(comboBox.getText(), textVisibleLeftPosition, editableArea); graphics.putString(0, 0, textToDraw); graphics.applyThemeStyle(themeDefinition.getInsensitive()); graphics.setCharacter(editableArea, 0, themeDefinition.getCharacter("POPUP_SEPARATOR", Symbols.SINGLE_LINE_VERTICAL)); if(comboBox.isFocused() && comboBox.isDropDownFocused()) { graphics.applyThemeStyle(themeDefinition.getSelected()); } graphics.setCharacter(editableArea + 1, 0, themeDefinition.getCharacter("POPUP", Symbols.TRIANGLE_DOWN_POINTING_BLACK)); } } }