Commit | Line | Data |
---|---|---|
a3b510ab NR |
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 | } |