Change build scripts
[jvcard.git] / src / com / googlecode / lanterna / gui2 / ComboBox.java
1 package com.googlecode.lanterna.gui2;
2
3 import com.googlecode.lanterna.*;
4 import com.googlecode.lanterna.input.KeyStroke;
5
6 import java.util.ArrayList;
7 import java.util.Arrays;
8 import java.util.Collection;
9 import java.util.List;
10 import java.util.concurrent.CopyOnWriteArrayList;
11
12 /**
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
17 * @author Martin
18 */
19 public class ComboBox<V> extends AbstractInteractableComponent<ComboBox<V>> {
20
21 /**
22 * Listener interface that can be used to catch user events on the combo box
23 */
24 public interface Listener {
25 /**
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
29 */
30 void onSelectionChanged(int selectedIndex, int previousSelection);
31 }
32
33 private final List<V> items;
34 private final List<Listener> listeners;
35
36 private PopupWindow popupWindow;
37 private String text;
38 private int selectedIndex;
39
40 private boolean readOnly;
41 private boolean dropDownFocused;
42 private int textInputPosition;
43
44 /**
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
48 */
49 public ComboBox(V... items) {
50 this(Arrays.asList(items));
51 }
52
53 /**
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
57 */
58 public ComboBox(Collection<V> items) {
59 this(items, items.isEmpty() ? -1 : 0);
60 }
61
62 /**
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
67 * manually changes it
68 *
69 * @param initialText Text to put in the combo box initially
70 * @param items Items to populate the new combo box with
71 */
72 public ComboBox(String initialText, Collection<V> items) {
73 this(items, -1);
74 this.text = initialText;
75 }
76
77 /**
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
82 */
83 public ComboBox(Collection<V> items, int selectedIndex) {
84 for(V item: items) {
85 if(item == null) {
86 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
87 }
88 }
89 this.items = new ArrayList<V>(items);
90 this.listeners = new CopyOnWriteArrayList<Listener>();
91 this.popupWindow = null;
92 this.selectedIndex = selectedIndex;
93 this.readOnly = true;
94 this.dropDownFocused = true;
95 this.textInputPosition = 0;
96 if(selectedIndex != -1) {
97 this.text = this.items.get(selectedIndex).toString();
98 }
99 else {
100 this.text = "";
101 }
102 }
103
104 /**
105 * Adds a new item to the combo box, at the end
106 * @param item Item to add to the combo box
107 * @return Itself
108 */
109 public synchronized ComboBox<V> addItem(V item) {
110 if(item == null) {
111 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
112 }
113 items.add(item);
114 if(selectedIndex == -1 && items.size() == 1) {
115 setSelectedIndex(0);
116 }
117 invalidate();
118 return this;
119 }
120
121 /**
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
125 * @return Itself
126 */
127 public synchronized ComboBox<V> addItem(int index, V item) {
128 if(item == null) {
129 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
130 }
131 items.add(index, item);
132 if(index <= selectedIndex) {
133 setSelectedIndex(selectedIndex + 1);
134 }
135 invalidate();
136 return this;
137 }
138
139 /**
140 * Removes all items from the combo box
141 * @return Itself
142 */
143 public synchronized ComboBox<V> clearItems() {
144 items.clear();
145 setSelectedIndex(-1);
146 invalidate();
147 return this;
148 }
149
150 /**
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
153 * @return Itself
154 */
155 public synchronized ComboBox<V> removeItem(V item) {
156 int index = items.indexOf(item);
157 if(index == -1) {
158 return this;
159 }
160 return remoteItem(index);
161 }
162
163 /**
164 * Removes an item from the combo box at a particular index
165 * @param index Index of the item to remove
166 * @return Itself
167 * @throws IndexOutOfBoundsException if the index is out of range
168 */
169 public synchronized ComboBox<V> remoteItem(int index) {
170 items.remove(index);
171 if(index < selectedIndex) {
172 setSelectedIndex(selectedIndex - 1);
173 }
174 else if(index == selectedIndex) {
175 setSelectedIndex(-1);
176 }
177 invalidate();
178 return this;
179 }
180
181 /**
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
186 * @return Itself
187 */
188 public synchronized ComboBox<V> setItem(int index, V item) {
189 if(item == null) {
190 throw new IllegalArgumentException("Cannot add null elements to a ComboBox");
191 }
192 items.set(index, item);
193 invalidate();
194 return this;
195 }
196
197 /**
198 * Counts and returns the number of items in this combo box
199 * @return Number of items in this combo box
200 */
201 public synchronized int getItemCount() {
202 return items.size();
203 }
204
205 /**
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
210 */
211 public synchronized V getItem(int index) {
212 return items.get(index);
213 }
214
215 /**
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
219 */
220 public String getText() {
221 return text;
222 }
223
224 /**
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
229 * writable mode
230 * @return Itself
231 */
232 public synchronized ComboBox<V> setReadOnly(boolean readOnly) {
233 this.readOnly = readOnly;
234 if(readOnly) {
235 dropDownFocused = true;
236 }
237 return this;
238 }
239
240 /**
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
243 */
244 public boolean isReadOnly() {
245 return readOnly;
246 }
247
248 /**
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
253 */
254 public boolean isDropDownFocused() {
255 return dropDownFocused || isReadOnly();
256 }
257
258 /**
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
263 * keyboard.
264 * @return Current text input position
265 */
266 public int getTextInputPosition() {
267 return textInputPosition;
268 }
269
270 /**
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
275 */
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);
279 }
280 final int oldSelection = this.selectedIndex;
281 this.selectedIndex = selectedIndex;
282 if(selectedIndex == -1) {
283 text = "";
284 }
285 else {
286 text = items.get(selectedIndex).toString();
287 }
288 if(textInputPosition > text.length()) {
289 textInputPosition = text.length();
290 }
291 runOnGUIThreadIfExistsOtherwiseRunDirect(new Runnable() {
292 @Override
293 public void run() {
294 for(Listener listener: listeners) {
295 listener.onSelectionChanged(selectedIndex, oldSelection);
296 }
297 }
298 });
299 invalidate();
300 }
301
302 /**
303 * Returns the index of the currently selected item
304 * @return Index of the currently selected item
305 */
306 public int getSelectedIndex() {
307 return selectedIndex;
308 }
309
310 /**
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}
313 * @return Itself
314 */
315 public ComboBox<V> addListener(Listener listener) {
316 if(listener != null && !listeners.contains(listener)) {
317 listeners.add(listener);
318 }
319 return this;
320 }
321
322 /**
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}
326 * @return Itself
327 */
328 public ComboBox<V> removeListener(Listener listener) {
329 listeners.remove(listener);
330 return this;
331 }
332
333 @Override
334 protected void afterEnterFocus(FocusChangeDirection direction, Interactable previouslyInFocus) {
335 if(direction == FocusChangeDirection.RIGHT && !isReadOnly()) {
336 dropDownFocused = false;
337 selectedIndex = 0;
338 }
339 }
340
341 @Override
342 protected void afterLeaveFocus(FocusChangeDirection direction, Interactable nextInFocus) {
343 if(popupWindow != null) {
344 popupWindow.close();
345 popupWindow = null;
346 }
347 }
348
349 @Override
350 protected InteractableRenderer<ComboBox<V>> createDefaultRenderer() {
351 return new DefaultComboBoxRenderer<V>();
352 }
353
354 @Override
355 public synchronized Result handleKeyStroke(KeyStroke keyStroke) {
356 if(isReadOnly()) {
357 return handleReadOnlyCBKeyStroke(keyStroke);
358 }
359 else {
360 return handleEditableCBKeyStroke(keyStroke);
361 }
362 }
363
364 private Result handleReadOnlyCBKeyStroke(KeyStroke keyStroke) {
365 switch(keyStroke.getKeyType()) {
366 case ArrowDown:
367 if(popupWindow != null) {
368 popupWindow.listBox.handleKeyStroke(keyStroke);
369 return Result.HANDLED;
370 }
371 return Result.MOVE_FOCUS_DOWN;
372
373 case ArrowUp:
374 if(popupWindow != null) {
375 popupWindow.listBox.handleKeyStroke(keyStroke);
376 return Result.HANDLED;
377 }
378 return Result.MOVE_FOCUS_UP;
379
380 case Enter:
381 if(popupWindow != null) {
382 popupWindow.listBox.handleKeyStroke(keyStroke);
383 popupWindow.close();
384 popupWindow = null;
385 }
386 else {
387 popupWindow = new PopupWindow();
388 popupWindow.setPosition(toGlobal(getPosition().withRelativeRow(1)));
389 ((WindowBasedTextGUI) getTextGUI()).addWindow(popupWindow);
390 }
391 break;
392
393 case Escape:
394 if(popupWindow != null) {
395 popupWindow.close();
396 popupWindow = null;
397 return Result.HANDLED;
398 }
399 break;
400
401 default:
402 }
403 return super.handleKeyStroke(keyStroke);
404 }
405
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()) {
410 case ReverseTab:
411 case ArrowLeft:
412 dropDownFocused = false;
413 textInputPosition = text.length();
414 return Result.HANDLED;
415
416 //The rest we can process in the same way as with read-only combo boxes when we are in drop-down focused mode
417 default:
418 return handleReadOnlyCBKeyStroke(keyStroke);
419 }
420 }
421
422 switch(keyStroke.getKeyType()) {
423 case Character:
424 text = text.substring(0, textInputPosition) + keyStroke.getCharacter() + text.substring(textInputPosition);
425 textInputPosition++;
426 return Result.HANDLED;
427
428 case Tab:
429 dropDownFocused = true;
430 return Result.HANDLED;
431
432 case Backspace:
433 if(textInputPosition > 0) {
434 text = text.substring(0, textInputPosition - 1) + text.substring(textInputPosition);
435 textInputPosition--;
436 }
437 return Result.HANDLED;
438
439 case Delete:
440 if(textInputPosition < text.length()) {
441 text = text.substring(0, textInputPosition) + text.substring(textInputPosition + 1);
442 }
443 return Result.HANDLED;
444
445 case ArrowLeft:
446 if(textInputPosition > 0) {
447 textInputPosition--;
448 }
449 else {
450 return Result.MOVE_FOCUS_LEFT;
451 }
452 return Result.HANDLED;
453
454 case ArrowRight:
455 if(textInputPosition < text.length()) {
456 textInputPosition++;
457 }
458 else {
459 dropDownFocused = true;
460 return Result.HANDLED;
461 }
462 return Result.HANDLED;
463
464 case ArrowDown:
465 if(selectedIndex < items.size() - 1) {
466 setSelectedIndex(selectedIndex + 1);
467 }
468 return Result.HANDLED;
469
470 case ArrowUp:
471 if(selectedIndex > 0) {
472 setSelectedIndex(selectedIndex - 1);
473 }
474 return Result.HANDLED;
475
476 default:
477 }
478 return super.handleKeyStroke(keyStroke);
479 }
480
481 private class PopupWindow extends BasicWindow {
482 private final ActionListBox listBox;
483
484 public PopupWindow() {
485 setHints(Arrays.asList(
486 Hint.NO_FOCUS,
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);
491 final int index = i;
492 listBox.addItem(item.toString(), new Runnable() {
493 @Override
494 public void run() {
495 setSelectedIndex(index);
496 close();
497 }
498 });
499 }
500 listBox.setSelectedIndex(getSelectedIndex());
501 setComponent(listBox);
502 }
503 }
504
505 /**
506 * Helper interface that doesn't add any new methods but makes coding new combo box renderers a little bit more clear
507 */
508 public static abstract class ComboBoxRenderer<V> implements InteractableRenderer<ComboBox<V>> {
509 }
510
511 /**
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
515 */
516 public static class DefaultComboBoxRenderer<V> extends ComboBoxRenderer<V> {
517
518 private int textVisibleLeftPosition;
519
520 /**
521 * Default constructor
522 */
523 public DefaultComboBoxRenderer() {
524 this.textVisibleLeftPosition = 0;
525 }
526
527 @Override
528 public TerminalPosition getCursorLocation(ComboBox<V> comboBox) {
529 if(comboBox.isDropDownFocused()) {
530 return new TerminalPosition(comboBox.getSize().getColumns() - 1, 0);
531 }
532 else {
533 int textInputPosition = comboBox.getTextInputPosition();
534 int textInputColumn = TerminalTextUtils.getColumnWidth(comboBox.getText().substring(0, textInputPosition));
535 return new TerminalPosition(textInputColumn - textVisibleLeftPosition, 0);
536 }
537 }
538
539 @Override
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
547 }
548 }
549 return size;
550 }
551
552 @Override
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);
559 }
560 graphics.fill(' ');
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;
566 }
567 if(columnsToInputPosition - textVisibleLeftPosition >= editableArea) {
568 textVisibleLeftPosition = columnsToInputPosition - editableArea + 1;
569 }
570 if(columnsToInputPosition - textVisibleLeftPosition + 1 == editableArea &&
571 comboBox.getText().length() > textInputPosition &&
572 TerminalTextUtils.isCharCJK(comboBox.getText().charAt(textInputPosition))) {
573 textVisibleLeftPosition++;
574 }
575
576 String textToDraw = TerminalTextUtils.fitString(comboBox.getText(), textVisibleLeftPosition, editableArea);
577 graphics.putString(0, 0, textToDraw);
578 if(comboBox.isFocused()) {
579 graphics.disableModifiers(SGR.BOLD);
580 }
581 graphics.setForegroundColor(TextColor.ANSI.BLACK);
582 graphics.setBackgroundColor(TextColor.ANSI.WHITE);
583 graphics.putString(editableArea, 0, "|" + Symbols.ARROW_DOWN);
584 }
585 }
586 }