1 package com
.googlecode
.lanterna
.gui2
;
3 import com
.googlecode
.lanterna
.*;
4 import com
.googlecode
.lanterna
.input
.KeyStroke
;
6 import java
.util
.ArrayList
;
7 import java
.util
.Arrays
;
8 import java
.util
.Collection
;
10 import java
.util
.concurrent
.CopyOnWriteArrayList
;
13 * This is a simple combo box implementation that allows the user to select one out of multiple items through a
14 * 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
15 * like a {@code TextBox}.
16 * @param <V> Type to use for the items in the combo box
19 public class ComboBox
<V
> extends AbstractInteractableComponent
<ComboBox
<V
>> {
22 * Listener interface that can be used to catch user events on the combo box
24 public interface Listener
{
26 * This method is called whenever the user changes selection from one item to another in the combo box
27 * @param selectedIndex Index of the item which is now selected
28 * @param previousSelection Index of the item which was previously selected
30 void onSelectionChanged(int selectedIndex
, int previousSelection
);
33 private final List
<V
> items
;
34 private final List
<Listener
> listeners
;
36 private PopupWindow popupWindow
;
38 private int selectedIndex
;
40 private boolean readOnly
;
41 private boolean dropDownFocused
;
42 private int textInputPosition
;
45 * Creates a new {@code ComboBox} initialized with N number of items supplied through the varargs parameter. If at
46 * least one item is given, the first one in the array will be initially selected
47 * @param items Items to populate the new combo box with
49 public ComboBox(V
... items
) {
50 this(Arrays
.asList(items
));
54 * Creates a new {@code ComboBox} initialized with N number of items supplied through the items parameter. If at
55 * least one item is given, the first one in the collection will be initially selected
56 * @param items Items to populate the new combo box with
58 public ComboBox(Collection
<V
> items
) {
59 this(items
, items
.isEmpty() ?
-1 : 0);
63 * Creates a new {@code ComboBox} initialized with N number of items supplied through the items parameter. The
64 * initial text in the combo box is set to a specific value passed in through the {@code initialText} parameter, it
65 * can be a text which is not contained within the items and the selection state of the combo box will be
66 * "no selection" (so {@code getSelectedIndex()} will return -1) until the user interacts with the combo box and
69 * @param initialText Text to put in the combo box initially
70 * @param items Items to populate the new combo box with
72 public ComboBox(String initialText
, Collection
<V
> items
) {
74 this.text
= initialText
;
78 * Creates a new {@code ComboBox} initialized with N number of items supplied through the items parameter. The
79 * initially selected item is specified through the {@code selectedIndex} parameter.
80 * @param items Items to populate the new combo box with
81 * @param selectedIndex Index of the item which should be initially selected
83 public ComboBox(Collection
<V
> items
, int selectedIndex
) {
86 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
89 this.items
= new ArrayList
<V
>(items
);
90 this.listeners
= new CopyOnWriteArrayList
<Listener
>();
91 this.popupWindow
= null;
92 this.selectedIndex
= selectedIndex
;
94 this.dropDownFocused
= true;
95 this.textInputPosition
= 0;
96 if(selectedIndex
!= -1) {
97 this.text
= this.items
.get(selectedIndex
).toString();
105 * Adds a new item to the combo box, at the end
106 * @param item Item to add to the combo box
109 public synchronized ComboBox
<V
> addItem(V item
) {
111 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
114 if(selectedIndex
== -1 && items
.size() == 1) {
122 * Adds a new item to the combo box, at a specific index
123 * @param index Index to add the item at
124 * @param item Item to add
127 public synchronized ComboBox
<V
> addItem(int index
, V item
) {
129 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
131 items
.add(index
, item
);
132 if(index
<= selectedIndex
) {
133 setSelectedIndex(selectedIndex
+ 1);
140 * Removes all items from the combo box
143 public synchronized ComboBox
<V
> clearItems() {
145 setSelectedIndex(-1);
151 * Removes a particular item from the combo box, if it is present, otherwise does nothing
152 * @param item Item to remove from the combo box
155 public synchronized ComboBox
<V
> removeItem(V item
) {
156 int index
= items
.indexOf(item
);
160 return remoteItem(index
);
164 * Removes an item from the combo box at a particular index
165 * @param index Index of the item to remove
167 * @throws IndexOutOfBoundsException if the index is out of range
169 public synchronized ComboBox
<V
> remoteItem(int index
) {
171 if(index
< selectedIndex
) {
172 setSelectedIndex(selectedIndex
- 1);
174 else if(index
== selectedIndex
) {
175 setSelectedIndex(-1);
182 * Updates the combo box so the item at the specified index is swapped out with the supplied value in the
183 * {@code item} parameter
184 * @param index Index of the item to swap out
185 * @param item Item to replace with
188 public synchronized ComboBox
<V
> setItem(int index
, V item
) {
190 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
192 items
.set(index
, item
);
198 * Counts and returns the number of items in this combo box
199 * @return Number of items in this combo box
201 public synchronized int getItemCount() {
206 * Returns the item at the specific index
207 * @param index Index of the item to return
208 * @return Item at the specific index
209 * @throws IndexOutOfBoundsException if the index is out of range
211 public synchronized V
getItem(int index
) {
212 return items
.get(index
);
216 * Returns the text currently displayed in the combo box, this will likely be the label of the selected item but for
217 * writable combo boxes it's also what the user has typed in
218 * @return String currently displayed in the combo box
220 public String
getText() {
225 * Sets the combo box to either read-only or writable. In read-only mode, the user cannot type in any text in the
226 * combo box but is forced to pick one of the items, displayed by the drop-down. In writable mode, the user can
227 * enter any string in the combo box
228 * @param readOnly If the combo box should be in read-only mode, pass in {@code true}, otherwise {@code false} for
232 public synchronized ComboBox
<V
> setReadOnly(boolean readOnly
) {
233 this.readOnly
= readOnly
;
235 dropDownFocused
= true;
241 * Returns {@code true} if this combo box is in read-only mode
242 * @return {@code true} if this combo box is in read-only mode, {@code false} otherwise
244 public boolean isReadOnly() {
249 * Returns {@code true} if the users input focus is currently on the drop-down button of the combo box, so that
250 * pressing enter would trigger the popup window. This is generally used by renderers only and is always true for
251 * read-only combo boxes as the component won't allow you to focus on the text in that mode.
252 * @return {@code true} if the input focus is on the drop-down "button" of the combo box
254 public boolean isDropDownFocused() {
255 return dropDownFocused
|| isReadOnly();
259 * For writable combo boxes, this method returns the position where the text input cursor is right now. Meaning, if
260 * the user types some character, where are those are going to be inserted in the string that is currently
261 * displayed. If the text input position equals the size of the currently displayed text, new characters will be
262 * appended at the end. The user can usually move the text input position by using left and right arrow keys on the
264 * @return Current text input position
266 public int getTextInputPosition() {
267 return textInputPosition
;
271 * Programmatically selects one item in the combo box, which causes the displayed text to change to match the label
272 * of the selected index
273 * @param selectedIndex Index of the item to select
274 * @throws IndexOutOfBoundsException if the index is out of range
276 public synchronized void setSelectedIndex(final int selectedIndex
) {
277 if(items
.size() <= selectedIndex
|| selectedIndex
< -1) {
278 throw new IndexOutOfBoundsException("Illegal argument to ComboBox.setSelectedIndex: " + selectedIndex
);
280 final int oldSelection
= this.selectedIndex
;
281 this.selectedIndex
= selectedIndex
;
282 if(selectedIndex
== -1) {
286 text
= items
.get(selectedIndex
).toString();
288 if(textInputPosition
> text
.length()) {
289 textInputPosition
= text
.length();
291 runOnGUIThreadIfExistsOtherwiseRunDirect(new Runnable() {
294 for(Listener listener
: listeners
) {
295 listener
.onSelectionChanged(selectedIndex
, oldSelection
);
303 * Returns the index of the currently selected item
304 * @return Index of the currently selected item
306 public int getSelectedIndex() {
307 return selectedIndex
;
311 * Adds a new listener to the {@code ComboBox} that will be called on certain user actions
312 * @param listener Listener to attach to this {@code ComboBox}
315 public ComboBox
<V
> addListener(Listener listener
) {
316 if(listener
!= null && !listeners
.contains(listener
)) {
317 listeners
.add(listener
);
323 * Removes a listener from this {@code ComboBox} so that if it had been added earlier, it will no longer be
324 * called on user actions
325 * @param listener Listener to remove from this {@code ComboBox}
328 public ComboBox
<V
> removeListener(Listener listener
) {
329 listeners
.remove(listener
);
334 protected void afterEnterFocus(FocusChangeDirection direction
, Interactable previouslyInFocus
) {
335 if(direction
== FocusChangeDirection
.RIGHT
&& !isReadOnly()) {
336 dropDownFocused
= false;
342 protected void afterLeaveFocus(FocusChangeDirection direction
, Interactable nextInFocus
) {
343 if(popupWindow
!= null) {
350 protected InteractableRenderer
<ComboBox
<V
>> createDefaultRenderer() {
351 return new DefaultComboBoxRenderer
<V
>();
355 public synchronized Result
handleKeyStroke(KeyStroke keyStroke
) {
357 return handleReadOnlyCBKeyStroke(keyStroke
);
360 return handleEditableCBKeyStroke(keyStroke
);
364 private Result
handleReadOnlyCBKeyStroke(KeyStroke keyStroke
) {
365 switch(keyStroke
.getKeyType()) {
367 if(popupWindow
!= null) {
368 popupWindow
.listBox
.handleKeyStroke(keyStroke
);
369 return Result
.HANDLED
;
371 return Result
.MOVE_FOCUS_DOWN
;
374 if(popupWindow
!= null) {
375 popupWindow
.listBox
.handleKeyStroke(keyStroke
);
376 return Result
.HANDLED
;
378 return Result
.MOVE_FOCUS_UP
;
381 if(popupWindow
!= null) {
382 popupWindow
.listBox
.handleKeyStroke(keyStroke
);
387 popupWindow
= new PopupWindow();
388 popupWindow
.setPosition(toGlobal(getPosition().withRelativeRow(1)));
389 ((WindowBasedTextGUI
) getTextGUI()).addWindow(popupWindow
);
394 if(popupWindow
!= null) {
397 return Result
.HANDLED
;
403 return super.handleKeyStroke(keyStroke
);
406 private Result
handleEditableCBKeyStroke(KeyStroke keyStroke
) {
407 //First check if we are in drop-down focused mode, treat keystrokes a bit differently then
408 if(isDropDownFocused()) {
409 switch(keyStroke
.getKeyType()) {
412 dropDownFocused
= false;
413 textInputPosition
= text
.length();
414 return Result
.HANDLED
;
416 //The rest we can process in the same way as with read-only combo boxes when we are in drop-down focused mode
418 return handleReadOnlyCBKeyStroke(keyStroke
);
422 switch(keyStroke
.getKeyType()) {
424 text
= text
.substring(0, textInputPosition
) + keyStroke
.getCharacter() + text
.substring(textInputPosition
);
426 return Result
.HANDLED
;
429 dropDownFocused
= true;
430 return Result
.HANDLED
;
433 if(textInputPosition
> 0) {
434 text
= text
.substring(0, textInputPosition
- 1) + text
.substring(textInputPosition
);
437 return Result
.HANDLED
;
440 if(textInputPosition
< text
.length()) {
441 text
= text
.substring(0, textInputPosition
) + text
.substring(textInputPosition
+ 1);
443 return Result
.HANDLED
;
446 if(textInputPosition
> 0) {
450 return Result
.MOVE_FOCUS_LEFT
;
452 return Result
.HANDLED
;
455 if(textInputPosition
< text
.length()) {
459 dropDownFocused
= true;
460 return Result
.HANDLED
;
462 return Result
.HANDLED
;
465 if(selectedIndex
< items
.size() - 1) {
466 setSelectedIndex(selectedIndex
+ 1);
468 return Result
.HANDLED
;
471 if(selectedIndex
> 0) {
472 setSelectedIndex(selectedIndex
- 1);
474 return Result
.HANDLED
;
478 return super.handleKeyStroke(keyStroke
);
481 private class PopupWindow
extends BasicWindow
{
482 private final ActionListBox listBox
;
484 public PopupWindow() {
485 setHints(Arrays
.asList(
487 Hint
.FIXED_POSITION
));
488 listBox
= new ActionListBox(ComboBox
.this.getSize().withRows(getItemCount()));
489 for(int i
= 0; i
< getItemCount(); i
++) {
490 V item
= items
.get(i
);
492 listBox
.addItem(item
.toString(), new Runnable() {
495 setSelectedIndex(index
);
500 listBox
.setSelectedIndex(getSelectedIndex());
501 setComponent(listBox
);
506 * Helper interface that doesn't add any new methods but makes coding new combo box renderers a little bit more clear
508 public static abstract class ComboBoxRenderer
<V
> implements InteractableRenderer
<ComboBox
<V
>> {
512 * This class is the default renderer implementation which will be used unless overridden. The combo box is rendered
513 * like a text box with an arrow point down to the right of it, which can receive focus and triggers the popup.
514 * @param <V> Type of items in the combo box
516 public static class DefaultComboBoxRenderer
<V
> extends ComboBoxRenderer
<V
> {
518 private int textVisibleLeftPosition
;
521 * Default constructor
523 public DefaultComboBoxRenderer() {
524 this.textVisibleLeftPosition
= 0;
528 public TerminalPosition
getCursorLocation(ComboBox
<V
> comboBox
) {
529 if(comboBox
.isDropDownFocused()) {
530 return new TerminalPosition(comboBox
.getSize().getColumns() - 1, 0);
533 int textInputPosition
= comboBox
.getTextInputPosition();
534 int textInputColumn
= TerminalTextUtils
.getColumnWidth(comboBox
.getText().substring(0, textInputPosition
));
535 return new TerminalPosition(textInputColumn
- textVisibleLeftPosition
, 0);
540 public TerminalSize
getPreferredSize(final ComboBox
<V
> comboBox
) {
541 TerminalSize size
= TerminalSize
.ONE
.withColumns(
542 (comboBox
.getItemCount() == 0 ? TerminalTextUtils
.getColumnWidth(comboBox
.getText()) : 0) + 2);
543 synchronized(comboBox
) {
544 for(int i
= 0; i
< comboBox
.getItemCount(); i
++) {
545 V item
= comboBox
.getItem(i
);
546 size
= size
.max(new TerminalSize(TerminalTextUtils
.getColumnWidth(item
.toString()) + 2 + 1, 1)); // +1 to add a single column of space
553 public void drawComponent(TextGUIGraphics graphics
, ComboBox
<V
> comboBox
) {
554 graphics
.setForegroundColor(TextColor
.ANSI
.WHITE
);
555 graphics
.setBackgroundColor(TextColor
.ANSI
.BLUE
);
556 if(comboBox
.isFocused()) {
557 graphics
.setForegroundColor(TextColor
.ANSI
.YELLOW
);
558 graphics
.enableModifiers(SGR
.BOLD
);
561 int editableArea
= graphics
.getSize().getColumns() - 2; //This is exclusing the 'drop-down arrow'
562 int textInputPosition
= comboBox
.getTextInputPosition();
563 int columnsToInputPosition
= TerminalTextUtils
.getColumnWidth(comboBox
.getText().substring(0, textInputPosition
));
564 if(columnsToInputPosition
< textVisibleLeftPosition
) {
565 textVisibleLeftPosition
= columnsToInputPosition
;
567 if(columnsToInputPosition
- textVisibleLeftPosition
>= editableArea
) {
568 textVisibleLeftPosition
= columnsToInputPosition
- editableArea
+ 1;
570 if(columnsToInputPosition
- textVisibleLeftPosition
+ 1 == editableArea
&&
571 comboBox
.getText().length() > textInputPosition
&&
572 TerminalTextUtils
.isCharCJK(comboBox
.getText().charAt(textInputPosition
))) {
573 textVisibleLeftPosition
++;
576 String textToDraw
= TerminalTextUtils
.fitString(comboBox
.getText(), textVisibleLeftPosition
, editableArea
);
577 graphics
.putString(0, 0, textToDraw
);
578 if(comboBox
.isFocused()) {
579 graphics
.disableModifiers(SGR
.BOLD
);
581 graphics
.setForegroundColor(TextColor
.ANSI
.BLACK
);
582 graphics
.setBackgroundColor(TextColor
.ANSI
.WHITE
);
583 graphics
.putString(editableArea
, 0, "|" + Symbols
.ARROW_DOWN
);