Commit | Line | Data |
---|---|---|
a3b510ab NR |
1 | /* |
2 | * This file is part of lanterna (http://code.google.com/p/lanterna/). | |
3 | * | |
4 | * lanterna is free software: you can redistribute it and/or modify | |
5 | * it under the terms of the GNU Lesser General Public License as published by | |
6 | * the Free Software Foundation, either version 3 of the License, or | |
7 | * (at your option) any later version. | |
8 | * | |
9 | * This program is distributed in the hope that it will be useful, | |
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
12 | * GNU Lesser General Public License for more details. | |
13 | * | |
14 | * You should have received a copy of the GNU Lesser General Public License | |
15 | * along with this program. If not, see <http://www.gnu.org/licenses/>. | |
16 | * | |
17 | * Copyright (C) 2010-2015 Martin | |
18 | */ | |
19 | package com.googlecode.lanterna.gui2; | |
20 | ||
21 | import com.googlecode.lanterna.TerminalTextUtils; | |
22 | import com.googlecode.lanterna.TerminalPosition; | |
23 | import com.googlecode.lanterna.TerminalSize; | |
24 | import com.googlecode.lanterna.input.KeyStroke; | |
25 | ||
26 | import java.util.ArrayList; | |
27 | import java.util.List; | |
28 | import java.util.regex.Pattern; | |
29 | ||
30 | /** | |
31 | * This component keeps a text content that is editable by the user. A TextBox can be single line or multiline and lets | |
32 | * the user navigate the cursor in the text area by using the arrow keys, page up, page down, home and end. For | |
33 | * multi-line {@code TextBox}:es, scrollbars will be automatically displayed if needed. | |
34 | * <p> | |
35 | * Size-wise, a {@code TextBox} should be hard-coded to a particular size, it's not good at guessing how large it should | |
36 | * be. You can do this through the constructor. | |
37 | */ | |
38 | public class TextBox extends AbstractInteractableComponent<TextBox> { | |
39 | ||
40 | /** | |
41 | * Enum value to force a {@code TextBox} to be either single line or multi line. This is usually auto-detected if | |
42 | * the text box has some initial content by scanning that content for \n characters. | |
43 | */ | |
44 | public enum Style { | |
45 | /** | |
46 | * The {@code TextBox} contains a single line of text and is typically drawn on one row | |
47 | */ | |
48 | SINGLE_LINE, | |
49 | /** | |
50 | * The {@code TextBox} contains a none, one or many lines of text and is normally drawn over multiple lines | |
51 | */ | |
52 | MULTI_LINE, | |
53 | ; | |
54 | } | |
55 | ||
56 | private final List<String> lines; | |
57 | private final Style style; | |
58 | ||
59 | private TerminalPosition caretPosition; | |
60 | private boolean caretWarp; | |
61 | private boolean readOnly; | |
62 | private boolean horizontalFocusSwitching; | |
63 | private boolean verticalFocusSwitching; | |
64 | private int maxLineLength; | |
65 | private int longestRow; | |
66 | private char unusedSpaceCharacter; | |
67 | private Character mask; | |
68 | private Pattern validationPattern; | |
69 | ||
70 | /** | |
71 | * Default constructor, this creates a single-line {@code TextBox} of size 10 which is initially empty | |
72 | */ | |
73 | public TextBox() { | |
74 | this(new TerminalSize(10, 1), "", Style.SINGLE_LINE); | |
75 | } | |
76 | ||
77 | /** | |
78 | * Constructor that creates a {@code TextBox} with an initial content and attempting to be big enough to display | |
79 | * the whole text at once without scrollbars | |
80 | * @param initialContent Initial content of the {@code TextBox} | |
81 | */ | |
82 | public TextBox(String initialContent) { | |
83 | this(null, initialContent, initialContent.contains("\n") ? Style.MULTI_LINE : Style.SINGLE_LINE); | |
84 | } | |
85 | ||
86 | /** | |
87 | * Creates a {@code TextBox} that has an initial content and attempting to be big enough to display the whole text | |
88 | * at once without scrollbars. | |
89 | * | |
90 | * @param initialContent Initial content of the {@code TextBox} | |
91 | * @param style Forced style instead of auto-detecting | |
92 | */ | |
93 | public TextBox(String initialContent, Style style) { | |
94 | this(null, initialContent, style); | |
95 | } | |
96 | ||
97 | /** | |
98 | * Creates a new empty {@code TextBox} with a specific size | |
99 | * @param preferredSize Size of the {@code TextBox} | |
100 | */ | |
101 | public TextBox(TerminalSize preferredSize) { | |
102 | this(preferredSize, (preferredSize != null && preferredSize.getRows() > 1) ? Style.MULTI_LINE : Style.SINGLE_LINE); | |
103 | } | |
104 | ||
105 | /** | |
106 | * Creates a new empty {@code TextBox} with a specific size and style | |
107 | * @param preferredSize Size of the {@code TextBox} | |
108 | * @param style Style to use | |
109 | */ | |
110 | public TextBox(TerminalSize preferredSize, Style style) { | |
111 | this(preferredSize, "", style); | |
112 | } | |
113 | ||
114 | /** | |
115 | * Creates a new empty {@code TextBox} with a specific size and initial content | |
116 | * @param preferredSize Size of the {@code TextBox} | |
117 | * @param initialContent Initial content of the {@code TextBox} | |
118 | */ | |
119 | public TextBox(TerminalSize preferredSize, String initialContent) { | |
120 | this(preferredSize, initialContent, (preferredSize != null && preferredSize.getRows() > 1) || initialContent.contains("\n") ? Style.MULTI_LINE : Style.SINGLE_LINE); | |
121 | } | |
122 | ||
123 | /** | |
124 | * Main constructor of the {@code TextBox} which decides size, initial content and style | |
125 | * @param preferredSize Size of the {@code TextBox} | |
126 | * @param initialContent Initial content of the {@code TextBox} | |
127 | * @param style Style to use for this {@code TextBox}, instead of auto-detecting | |
128 | */ | |
129 | public TextBox(TerminalSize preferredSize, String initialContent, Style style) { | |
130 | this.lines = new ArrayList<String>(); | |
131 | this.style = style; | |
132 | this.readOnly = false; | |
133 | this.caretWarp = false; | |
134 | this.verticalFocusSwitching = true; | |
135 | this.horizontalFocusSwitching = (style == Style.SINGLE_LINE); | |
136 | this.caretPosition = TerminalPosition.TOP_LEFT_CORNER; | |
137 | this.maxLineLength = -1; | |
138 | this.longestRow = 1; //To fit the cursor | |
139 | this.unusedSpaceCharacter = ' '; | |
140 | this.mask = null; | |
141 | this.validationPattern = null; | |
142 | setText(initialContent); | |
143 | if (preferredSize == null) { | |
144 | preferredSize = new TerminalSize(Math.max(10, longestRow), lines.size()); | |
145 | } | |
146 | setPreferredSize(preferredSize); | |
147 | } | |
148 | ||
149 | /** | |
150 | * Sets a pattern on which the content of the text box is to be validated. For multi-line TextBox:s, the pattern is | |
151 | * checked against each line individually, not the content as a whole. Partial matchings will not be allowed, the | |
152 | * whole pattern must match, however, empty lines will always be allowed. When the user tried to modify the content | |
153 | * of the TextBox in a way that does not match the pattern, the operation will be silently ignored. If you set this | |
154 | * pattern to {@code null}, all validation is turned off. | |
155 | * @param validationPattern Pattern to validate the lines in this TextBox against, or {@code null} to disable | |
156 | * @return itself | |
157 | */ | |
158 | public synchronized TextBox setValidationPattern(Pattern validationPattern) { | |
159 | if(validationPattern != null) { | |
160 | for(String line: lines) { | |
161 | if(!validated(line)) { | |
162 | throw new IllegalStateException("TextBox validation pattern " + validationPattern + " does not match existing content"); | |
163 | } | |
164 | } | |
165 | } | |
166 | this.validationPattern = validationPattern; | |
167 | return this; | |
168 | } | |
169 | ||
170 | /** | |
171 | * Updates the text content of the {@code TextBox} to the supplied string. | |
172 | * @param text New text to assign to the {@code TextBox} | |
173 | * @return Itself | |
174 | */ | |
175 | public synchronized TextBox setText(String text) { | |
176 | String[] split = text.split("\n"); | |
177 | lines.clear(); | |
178 | longestRow = 1; | |
179 | for(String line : split) { | |
180 | addLine(line); | |
181 | } | |
182 | if(caretPosition.getRow() > lines.size() - 1) { | |
183 | caretPosition = caretPosition.withRow(lines.size() - 1); | |
184 | } | |
185 | if(caretPosition.getColumn() > lines.get(caretPosition.getRow()).length()) { | |
186 | caretPosition = caretPosition.withColumn(lines.get(caretPosition.getRow()).length()); | |
187 | } | |
188 | invalidate(); | |
189 | return this; | |
190 | } | |
191 | ||
192 | @Override | |
193 | public TextBoxRenderer getRenderer() { | |
194 | return (TextBoxRenderer)super.getRenderer(); | |
195 | } | |
196 | ||
197 | /** | |
198 | * Adds a single line to the {@code TextBox} at the end, this only works when in multi-line mode | |
199 | * @param line Line to add at the end of the content in this {@code TextBox} | |
200 | * @return Itself | |
201 | */ | |
202 | public synchronized TextBox addLine(String line) { | |
203 | StringBuilder bob = new StringBuilder(); | |
204 | for(int i = 0; i < line.length(); i++) { | |
205 | char c = line.charAt(i); | |
206 | if(c == '\n' && style == Style.MULTI_LINE) { | |
207 | String string = bob.toString(); | |
208 | int lineWidth = TerminalTextUtils.getColumnWidth(string); | |
209 | lines.add(string); | |
210 | if(longestRow < lineWidth + 1) { | |
211 | longestRow = lineWidth + 1; | |
212 | } | |
213 | addLine(line.substring(i + 1)); | |
214 | return this; | |
215 | } | |
216 | else if(Character.isISOControl(c)) { | |
217 | continue; | |
218 | } | |
219 | ||
220 | bob.append(c); | |
221 | } | |
222 | String string = bob.toString(); | |
223 | if(!validated(string)) { | |
224 | throw new IllegalStateException("TextBox validation pattern " + validationPattern + " does not match the supplied text"); | |
225 | } | |
226 | int lineWidth = TerminalTextUtils.getColumnWidth(string); | |
227 | lines.add(string); | |
228 | if(longestRow < lineWidth + 1) { | |
229 | longestRow = lineWidth + 1; | |
230 | } | |
231 | invalidate(); | |
232 | return this; | |
233 | } | |
234 | ||
235 | /** | |
236 | * Sets if the caret should jump to the beginning of the next line if right arrow is pressed while at the end of a | |
237 | * line. Similarly, pressing left arrow at the beginning of a line will make the caret jump to the end of the | |
238 | * previous line. This only makes sense for multi-line TextBox:es; for single-line ones it has no effect. By default | |
239 | * this is {@code false}. | |
240 | * @param caretWarp Whether the caret will warp at the beginning/end of lines | |
241 | * @return Itself | |
242 | */ | |
243 | public TextBox setCaretWarp(boolean caretWarp) { | |
244 | this.caretWarp = caretWarp; | |
245 | return this; | |
246 | } | |
247 | ||
248 | /** | |
249 | * Checks whether caret warp mode is enabled or not. See {@code setCaretWarp} for more details. | |
250 | * @return {@code true} if caret warp mode is enabled | |
251 | */ | |
252 | public boolean isCaretWarp() { | |
253 | return caretWarp; | |
254 | } | |
255 | ||
256 | /** | |
257 | * Returns the position of the caret, as a {@code TerminalPosition} where the row and columns equals the coordinates | |
258 | * in a multi-line {@code TextBox} and for single-line {@code TextBox} you can ignore the {@code row} component. | |
259 | * @return Position of the text input caret | |
260 | */ | |
261 | public TerminalPosition getCaretPosition() { | |
262 | return caretPosition; | |
263 | } | |
264 | ||
265 | /** | |
266 | * Returns the text in this {@code TextBox}, for multi-line mode all lines will be concatenated together with \n as | |
267 | * separator. | |
268 | * @return The text inside this {@code TextBox} | |
269 | */ | |
270 | public synchronized String getText() { | |
271 | StringBuilder bob = new StringBuilder(lines.get(0)); | |
272 | for(int i = 1; i < lines.size(); i++) { | |
273 | bob.append("\n").append(lines.get(i)); | |
274 | } | |
275 | return bob.toString(); | |
276 | } | |
277 | ||
278 | /** | |
279 | * Helper method, it will return the content of the {@code TextBox} unless it's empty in which case it will return | |
280 | * the supplied default value | |
281 | * @param defaultValueIfEmpty Value to return if the {@code TextBox} is empty | |
282 | * @return Text in the {@code TextBox} or {@code defaultValueIfEmpty} is the {@code TextBox} is empty | |
283 | */ | |
284 | public String getTextOrDefault(String defaultValueIfEmpty) { | |
285 | String text = getText(); | |
286 | if(text.isEmpty()) { | |
287 | return defaultValueIfEmpty; | |
288 | } | |
289 | return text; | |
290 | } | |
291 | ||
292 | /** | |
293 | * Returns the current text mask, meaning the substitute to draw instead of the text inside the {@code TextBox}. | |
294 | * This is normally used for password input fields so the password isn't shown | |
295 | * @return Current text mask or {@code null} if there is no mask | |
296 | */ | |
297 | public Character getMask() { | |
298 | return mask; | |
299 | } | |
300 | ||
301 | /** | |
302 | * Sets the current text mask, meaning the substitute to draw instead of the text inside the {@code TextBox}. | |
303 | * This is normally used for password input fields so the password isn't shown | |
304 | * @param mask New text mask or {@code null} if there is no mask | |
305 | * @return Itself | |
306 | */ | |
307 | public TextBox setMask(Character mask) { | |
308 | if(mask != null && TerminalTextUtils.isCharCJK(mask)) { | |
309 | throw new IllegalArgumentException("Cannot use a CJK character as a mask"); | |
310 | } | |
311 | this.mask = mask; | |
312 | invalidate(); | |
313 | return this; | |
314 | } | |
315 | ||
316 | /** | |
317 | * Returns {@code true} if this {@code TextBox} is in read-only mode, meaning text input from the user through the | |
318 | * keyboard is prevented | |
319 | * @return {@code true} if this {@code TextBox} is in read-only mode | |
320 | */ | |
321 | public boolean isReadOnly() { | |
322 | return readOnly; | |
323 | } | |
324 | ||
325 | /** | |
326 | * Sets the read-only mode of the {@code TextBox}, meaning text input from the user through the keyboard is | |
327 | * prevented. The user can still focus and scroll through the text in this mode. | |
328 | * @param readOnly If {@code true} then the {@code TextBox} will switch to read-only mode | |
329 | * @return Itself | |
330 | */ | |
331 | public TextBox setReadOnly(boolean readOnly) { | |
332 | this.readOnly = readOnly; | |
333 | invalidate(); | |
334 | return this; | |
335 | } | |
336 | ||
337 | /** | |
338 | * If {@code true}, the component will switch to the next available component above if the cursor is at the top of | |
339 | * the TextBox and the user presses the 'up' array key, or switch to the next available component below if the | |
340 | * cursor is at the bottom of the TextBox and the user presses the 'down' array key. The means that for single-line | |
341 | * TextBox:es, pressing up and down will always switch focus. | |
342 | * @return {@code true} if vertical focus switching is enabled | |
343 | */ | |
344 | public boolean isVerticalFocusSwitching() { | |
345 | return verticalFocusSwitching; | |
346 | } | |
347 | ||
348 | /** | |
349 | * If set to {@code true}, the component will switch to the next available component above if the cursor is at the | |
350 | * top of the TextBox and the user presses the 'up' array key, or switch to the next available component below if | |
351 | * the cursor is at the bottom of the TextBox and the user presses the 'down' array key. The means that for | |
352 | * single-line TextBox:es, pressing up and down will always switch focus with this mode enabled. | |
353 | * @param verticalFocusSwitching If called with true, vertical focus switching will be enabled | |
354 | * @return Itself | |
355 | */ | |
356 | public TextBox setVerticalFocusSwitching(boolean verticalFocusSwitching) { | |
357 | this.verticalFocusSwitching = verticalFocusSwitching; | |
358 | return this; | |
359 | } | |
360 | ||
361 | /** | |
362 | * If {@code true}, the TextBox will switch focus to the next available component to the left if the cursor in the | |
363 | * TextBox is at the left-most position (index 0) on the row and the user pressed the 'left' arrow key, or vice | |
364 | * versa for pressing the 'right' arrow key when the cursor in at the right-most position of the current row. | |
365 | * @return {@code true} if horizontal focus switching is enabled | |
366 | */ | |
367 | public boolean isHorizontalFocusSwitching() { | |
368 | return horizontalFocusSwitching; | |
369 | } | |
370 | ||
371 | /** | |
372 | * If set to {@code true}, the TextBox will switch focus to the next available component to the left if the cursor | |
373 | * in the TextBox is at the left-most position (index 0) on the row and the user pressed the 'left' arrow key, or | |
374 | * vice versa for pressing the 'right' arrow key when the cursor in at the right-most position of the current row. | |
375 | * @param horizontalFocusSwitching If called with true, horizontal focus switching will be enabled | |
376 | * @return Itself | |
377 | */ | |
378 | public TextBox setHorizontalFocusSwitching(boolean horizontalFocusSwitching) { | |
379 | this.horizontalFocusSwitching = horizontalFocusSwitching; | |
380 | return this; | |
381 | } | |
382 | ||
383 | /** | |
384 | * Returns the line on the specific row. For non-multiline TextBox:es, calling this with index set to 0 will return | |
385 | * the same as calling {@code getText()}. If the row index is invalid (less than zero or equals or larger than the | |
386 | * number of rows), this method will throw IndexOutOfBoundsException. | |
387 | * @param index | |
388 | * @return The line at the specified index, as a String | |
389 | * @throws IndexOutOfBoundsException if the row index is less than zero or too large | |
390 | */ | |
391 | public synchronized String getLine(int index) { | |
392 | return lines.get(index); | |
393 | } | |
394 | ||
395 | /** | |
396 | * Returns the number of lines currently in this TextBox. For single-line TextBox:es, this will always return 1. | |
397 | * @return Number of lines of text currently in this TextBox | |
398 | */ | |
399 | public synchronized int getLineCount() { | |
400 | return lines.size(); | |
401 | } | |
402 | ||
403 | @Override | |
404 | protected TextBoxRenderer createDefaultRenderer() { | |
405 | return new DefaultTextBoxRenderer(); | |
406 | } | |
407 | ||
408 | @Override | |
409 | public synchronized Result handleKeyStroke(KeyStroke keyStroke) { | |
410 | if(readOnly) { | |
411 | return handleKeyStrokeReadOnly(keyStroke); | |
412 | } | |
413 | String line = lines.get(caretPosition.getRow()); | |
414 | switch(keyStroke.getKeyType()) { | |
415 | case Character: | |
416 | if(maxLineLength == -1 || maxLineLength > line.length() + 1) { | |
417 | line = line.substring(0, caretPosition.getColumn()) + keyStroke.getCharacter() + line.substring(caretPosition.getColumn()); | |
418 | if(validated(line)) { | |
419 | lines.set(caretPosition.getRow(), line); | |
420 | caretPosition = caretPosition.withRelativeColumn(1); | |
421 | } | |
422 | } | |
423 | return Result.HANDLED; | |
424 | case Backspace: | |
425 | if(caretPosition.getColumn() > 0) { | |
426 | line = line.substring(0, caretPosition.getColumn() - 1) + line.substring(caretPosition.getColumn()); | |
427 | if(validated(line)) { | |
428 | lines.set(caretPosition.getRow(), line); | |
429 | caretPosition = caretPosition.withRelativeColumn(-1); | |
430 | } | |
431 | } | |
432 | else if(style == Style.MULTI_LINE && caretPosition.getRow() > 0) { | |
433 | String concatenatedLines = lines.get(caretPosition.getRow() - 1) + line; | |
434 | if(validated(concatenatedLines)) { | |
435 | lines.remove(caretPosition.getRow()); | |
436 | caretPosition = caretPosition.withRelativeRow(-1); | |
437 | caretPosition = caretPosition.withColumn(lines.get(caretPosition.getRow()).length()); | |
438 | lines.set(caretPosition.getRow(), concatenatedLines); | |
439 | } | |
440 | } | |
441 | return Result.HANDLED; | |
442 | case Delete: | |
443 | if(caretPosition.getColumn() < line.length()) { | |
444 | line = line.substring(0, caretPosition.getColumn()) + line.substring(caretPosition.getColumn() + 1); | |
445 | if(validated(line)) { | |
446 | lines.set(caretPosition.getRow(), line); | |
447 | } | |
448 | } | |
449 | else if(style == Style.MULTI_LINE && caretPosition.getRow() < lines.size() - 1) { | |
450 | String concatenatedLines = line + lines.get(caretPosition.getRow() + 1); | |
451 | if(validated(concatenatedLines)) { | |
452 | lines.set(caretPosition.getRow(), concatenatedLines); | |
453 | lines.remove(caretPosition.getRow() + 1); | |
454 | } | |
455 | } | |
456 | return Result.HANDLED; | |
457 | case ArrowLeft: | |
458 | if(caretPosition.getColumn() > 0) { | |
459 | caretPosition = caretPosition.withRelativeColumn(-1); | |
460 | } | |
461 | else if(style == Style.MULTI_LINE && caretWarp && caretPosition.getRow() > 0) { | |
462 | caretPosition = caretPosition.withRelativeRow(-1); | |
463 | caretPosition = caretPosition.withColumn(lines.get(caretPosition.getRow()).length()); | |
464 | } | |
465 | else if(horizontalFocusSwitching) { | |
466 | return Result.MOVE_FOCUS_LEFT; | |
467 | } | |
468 | return Result.HANDLED; | |
469 | case ArrowRight: | |
470 | if(caretPosition.getColumn() < lines.get(caretPosition.getRow()).length()) { | |
471 | caretPosition = caretPosition.withRelativeColumn(1); | |
472 | } | |
473 | else if(style == Style.MULTI_LINE && caretWarp && caretPosition.getRow() < lines.size() - 1) { | |
474 | caretPosition = caretPosition.withRelativeRow(1); | |
475 | caretPosition = caretPosition.withColumn(0); | |
476 | } | |
477 | else if(horizontalFocusSwitching) { | |
478 | return Result.MOVE_FOCUS_RIGHT; | |
479 | } | |
480 | return Result.HANDLED; | |
481 | case ArrowUp: | |
482 | if(caretPosition.getRow() > 0) { | |
483 | int trueColumnPosition = TerminalTextUtils.getColumnIndex(lines.get(caretPosition.getRow()), caretPosition.getColumn()); | |
484 | caretPosition = caretPosition.withRelativeRow(-1); | |
485 | line = lines.get(caretPosition.getRow()); | |
486 | if(trueColumnPosition > TerminalTextUtils.getColumnWidth(line)) { | |
487 | caretPosition = caretPosition.withColumn(line.length()); | |
488 | } | |
489 | else { | |
490 | caretPosition = caretPosition.withColumn(TerminalTextUtils.getStringCharacterIndex(line, trueColumnPosition)); | |
491 | } | |
492 | } | |
493 | else if(verticalFocusSwitching) { | |
494 | return Result.MOVE_FOCUS_UP; | |
495 | } | |
496 | return Result.HANDLED; | |
497 | case ArrowDown: | |
498 | if(caretPosition.getRow() < lines.size() - 1) { | |
499 | int trueColumnPosition = TerminalTextUtils.getColumnIndex(lines.get(caretPosition.getRow()), caretPosition.getColumn()); | |
500 | caretPosition = caretPosition.withRelativeRow(1); | |
501 | line = lines.get(caretPosition.getRow()); | |
502 | if(trueColumnPosition > TerminalTextUtils.getColumnWidth(line)) { | |
503 | caretPosition = caretPosition.withColumn(line.length()); | |
504 | } | |
505 | else { | |
506 | caretPosition = caretPosition.withColumn(TerminalTextUtils.getStringCharacterIndex(line, trueColumnPosition)); | |
507 | } | |
508 | } | |
509 | else if(verticalFocusSwitching) { | |
510 | return Result.MOVE_FOCUS_DOWN; | |
511 | } | |
512 | return Result.HANDLED; | |
513 | case End: | |
514 | caretPosition = caretPosition.withColumn(line.length()); | |
515 | return Result.HANDLED; | |
516 | case Enter: | |
517 | if(style == Style.SINGLE_LINE) { | |
518 | return Result.MOVE_FOCUS_NEXT; | |
519 | } | |
520 | String newLine = line.substring(caretPosition.getColumn()); | |
521 | String oldLine = line.substring(0, caretPosition.getColumn()); | |
522 | if(validated(newLine) && validated(oldLine)) { | |
523 | lines.set(caretPosition.getRow(), oldLine); | |
524 | lines.add(caretPosition.getRow() + 1, newLine); | |
525 | caretPosition = caretPosition.withColumn(0).withRelativeRow(1); | |
526 | } | |
527 | return Result.HANDLED; | |
528 | case Home: | |
529 | caretPosition = caretPosition.withColumn(0); | |
530 | return Result.HANDLED; | |
531 | case PageDown: | |
532 | caretPosition = caretPosition.withRelativeRow(getSize().getRows()); | |
533 | if(caretPosition.getRow() > lines.size() - 1) { | |
534 | caretPosition = caretPosition.withRow(lines.size() - 1); | |
535 | } | |
536 | if(lines.get(caretPosition.getRow()).length() < caretPosition.getColumn()) { | |
537 | caretPosition = caretPosition.withColumn(lines.get(caretPosition.getRow()).length()); | |
538 | } | |
539 | return Result.HANDLED; | |
540 | case PageUp: | |
541 | caretPosition = caretPosition.withRelativeRow(-getSize().getRows()); | |
542 | if(caretPosition.getRow() < 0) { | |
543 | caretPosition = caretPosition.withRow(0); | |
544 | } | |
545 | if(lines.get(caretPosition.getRow()).length() < caretPosition.getColumn()) { | |
546 | caretPosition = caretPosition.withColumn(lines.get(caretPosition.getRow()).length()); | |
547 | } | |
548 | return Result.HANDLED; | |
549 | default: | |
550 | } | |
551 | return super.handleKeyStroke(keyStroke); | |
552 | } | |
553 | ||
554 | private boolean validated(String line) { | |
555 | return validationPattern == null || line.isEmpty() || validationPattern.matcher(line).matches(); | |
556 | } | |
557 | ||
558 | private Result handleKeyStrokeReadOnly(KeyStroke keyStroke) { | |
559 | switch (keyStroke.getKeyType()) { | |
560 | case ArrowLeft: | |
561 | if(getRenderer().getViewTopLeft().getColumn() == 0 && horizontalFocusSwitching) { | |
562 | return Result.MOVE_FOCUS_LEFT; | |
563 | } | |
564 | getRenderer().setViewTopLeft(getRenderer().getViewTopLeft().withRelativeColumn(-1)); | |
565 | return Result.HANDLED; | |
566 | case ArrowRight: | |
567 | if(getRenderer().getViewTopLeft().getColumn() + getSize().getColumns() == longestRow && horizontalFocusSwitching) { | |
568 | return Result.MOVE_FOCUS_RIGHT; | |
569 | } | |
570 | getRenderer().setViewTopLeft(getRenderer().getViewTopLeft().withRelativeColumn(1)); | |
571 | return Result.HANDLED; | |
572 | case ArrowUp: | |
573 | if(getRenderer().getViewTopLeft().getRow() == 0 && verticalFocusSwitching) { | |
574 | return Result.MOVE_FOCUS_UP; | |
575 | } | |
576 | getRenderer().setViewTopLeft(getRenderer().getViewTopLeft().withRelativeRow(-1)); | |
577 | return Result.HANDLED; | |
578 | case ArrowDown: | |
579 | if(getRenderer().getViewTopLeft().getRow() + getSize().getRows() == lines.size() && verticalFocusSwitching) { | |
580 | return Result.MOVE_FOCUS_DOWN; | |
581 | } | |
582 | getRenderer().setViewTopLeft(getRenderer().getViewTopLeft().withRelativeRow(1)); | |
583 | return Result.HANDLED; | |
584 | case Home: | |
585 | getRenderer().setViewTopLeft(TerminalPosition.TOP_LEFT_CORNER); | |
586 | return Result.HANDLED; | |
587 | case End: | |
588 | getRenderer().setViewTopLeft(TerminalPosition.TOP_LEFT_CORNER.withRow(getLineCount() - getSize().getRows())); | |
589 | return Result.HANDLED; | |
590 | case PageDown: | |
591 | getRenderer().setViewTopLeft(getRenderer().getViewTopLeft().withRelativeRow(getSize().getRows())); | |
592 | return Result.HANDLED; | |
593 | case PageUp: | |
594 | getRenderer().setViewTopLeft(getRenderer().getViewTopLeft().withRelativeRow(-getSize().getRows())); | |
595 | return Result.HANDLED; | |
596 | default: | |
597 | } | |
598 | return super.handleKeyStroke(keyStroke); | |
599 | } | |
600 | ||
601 | /** | |
602 | * Helper interface that doesn't add any new methods but makes coding new text box renderers a little bit more clear | |
603 | */ | |
604 | public interface TextBoxRenderer extends InteractableRenderer<TextBox> { | |
605 | TerminalPosition getViewTopLeft(); | |
606 | void setViewTopLeft(TerminalPosition position); | |
607 | } | |
608 | ||
609 | /** | |
610 | * This is the default text box renderer that is used if you don't override anything. With this renderer, the text | |
611 | * box is filled with a solid background color and the text is drawn on top of it. Scrollbars are added for | |
612 | * multi-line text whenever the text inside the {@code TextBox} does not fit in the available area. | |
613 | */ | |
614 | public static class DefaultTextBoxRenderer implements TextBoxRenderer { | |
615 | private TerminalPosition viewTopLeft; | |
616 | private ScrollBar verticalScrollBar; | |
617 | private ScrollBar horizontalScrollBar; | |
618 | private boolean hideScrollBars; | |
619 | ||
620 | /** | |
621 | * Default constructor | |
622 | */ | |
623 | public DefaultTextBoxRenderer() { | |
624 | viewTopLeft = TerminalPosition.TOP_LEFT_CORNER; | |
625 | verticalScrollBar = new ScrollBar(Direction.VERTICAL); | |
626 | horizontalScrollBar = new ScrollBar(Direction.HORIZONTAL); | |
627 | hideScrollBars = false; | |
628 | } | |
629 | ||
630 | @Override | |
631 | public TerminalPosition getViewTopLeft() { | |
632 | return viewTopLeft; | |
633 | } | |
634 | ||
635 | @Override | |
636 | public void setViewTopLeft(TerminalPosition position) { | |
637 | if(position.getColumn() < 0) { | |
638 | position = position.withColumn(0); | |
639 | } | |
640 | if(position.getRow() < 0) { | |
641 | position = position.withRow(0); | |
642 | } | |
643 | viewTopLeft = position; | |
644 | } | |
645 | ||
646 | @Override | |
647 | public TerminalPosition getCursorLocation(TextBox component) { | |
648 | if(component.isReadOnly()) { | |
649 | return null; | |
650 | } | |
651 | ||
652 | //Adjust caret position if necessary | |
653 | TerminalPosition caretPosition = component.getCaretPosition(); | |
654 | String line = component.getLine(caretPosition.getRow()); | |
655 | caretPosition = caretPosition.withColumn(Math.min(caretPosition.getColumn(), line.length())); | |
656 | ||
657 | return caretPosition | |
658 | .withColumn(TerminalTextUtils.getColumnIndex(line, caretPosition.getColumn())) | |
659 | .withRelativeColumn(-viewTopLeft.getColumn()) | |
660 | .withRelativeRow(-viewTopLeft.getRow()); | |
661 | } | |
662 | ||
663 | @Override | |
664 | public TerminalSize getPreferredSize(TextBox component) { | |
665 | return new TerminalSize(component.longestRow, component.lines.size()); | |
666 | } | |
667 | ||
668 | /** | |
669 | * Controls whether scrollbars should be visible or not when a multi-line {@code TextBox} has more content than | |
670 | * it can draw in the area it was assigned (default: false) | |
671 | * @param hideScrollBars If {@code true}, don't show scrollbars if the multi-line content is bigger than the | |
672 | * area | |
673 | */ | |
674 | public void setHideScrollBars(boolean hideScrollBars) { | |
675 | this.hideScrollBars = hideScrollBars; | |
676 | } | |
677 | ||
678 | @Override | |
679 | public void drawComponent(TextGUIGraphics graphics, TextBox component) { | |
680 | TerminalSize realTextArea = graphics.getSize(); | |
681 | if(realTextArea.getRows() == 0 || realTextArea.getColumns() == 0) { | |
682 | return; | |
683 | } | |
684 | boolean drawVerticalScrollBar = false; | |
685 | boolean drawHorizontalScrollBar = false; | |
686 | int textBoxLineCount = component.getLineCount(); | |
687 | if(!hideScrollBars && textBoxLineCount > realTextArea.getRows() && realTextArea.getColumns() > 1) { | |
688 | realTextArea = realTextArea.withRelativeColumns(-1); | |
689 | drawVerticalScrollBar = true; | |
690 | } | |
691 | if(!hideScrollBars && component.longestRow > realTextArea.getColumns() && realTextArea.getRows() > 1) { | |
692 | realTextArea = realTextArea.withRelativeRows(-1); | |
693 | drawHorizontalScrollBar = true; | |
694 | if(textBoxLineCount > realTextArea.getRows() && realTextArea.getRows() == graphics.getSize().getRows()) { | |
695 | realTextArea = realTextArea.withRelativeColumns(-1); | |
696 | drawVerticalScrollBar = true; | |
697 | } | |
698 | } | |
699 | ||
700 | drawTextArea(graphics.newTextGraphics(TerminalPosition.TOP_LEFT_CORNER, realTextArea), component); | |
701 | ||
702 | //Draw scrollbars, if any | |
703 | if(drawVerticalScrollBar) { | |
704 | verticalScrollBar.setViewSize(realTextArea.getRows()); | |
705 | verticalScrollBar.setScrollMaximum(textBoxLineCount); | |
706 | verticalScrollBar.setScrollPosition(viewTopLeft.getRow()); | |
707 | verticalScrollBar.draw(graphics.newTextGraphics( | |
708 | new TerminalPosition(graphics.getSize().getColumns() - 1, 0), | |
709 | new TerminalSize(1, graphics.getSize().getRows() - 1))); | |
710 | } | |
711 | if(drawHorizontalScrollBar) { | |
712 | horizontalScrollBar.setViewSize(realTextArea.getColumns()); | |
713 | horizontalScrollBar.setScrollMaximum(component.longestRow - 1); | |
714 | horizontalScrollBar.setScrollPosition(viewTopLeft.getColumn()); | |
715 | horizontalScrollBar.draw(graphics.newTextGraphics( | |
716 | new TerminalPosition(0, graphics.getSize().getRows() - 1), | |
717 | new TerminalSize(graphics.getSize().getColumns() - 1, 1))); | |
718 | } | |
719 | } | |
720 | ||
721 | private void drawTextArea(TextGUIGraphics graphics, TextBox component) { | |
722 | TerminalSize textAreaSize = graphics.getSize(); | |
723 | if(viewTopLeft.getColumn() + textAreaSize.getColumns() > component.longestRow) { | |
724 | viewTopLeft = viewTopLeft.withColumn(component.longestRow - textAreaSize.getColumns()); | |
725 | if(viewTopLeft.getColumn() < 0) { | |
726 | viewTopLeft = viewTopLeft.withColumn(0); | |
727 | } | |
728 | } | |
729 | if(viewTopLeft.getRow() + textAreaSize.getRows() > component.getLineCount()) { | |
730 | viewTopLeft = viewTopLeft.withRow(component.getLineCount() - textAreaSize.getRows()); | |
731 | if(viewTopLeft.getRow() < 0) { | |
732 | viewTopLeft = viewTopLeft.withRow(0); | |
733 | } | |
734 | } | |
735 | if (component.isFocused()) { | |
736 | graphics.applyThemeStyle(graphics.getThemeDefinition(TextBox.class).getActive()); | |
737 | } | |
738 | else { | |
739 | graphics.applyThemeStyle(graphics.getThemeDefinition(TextBox.class).getNormal()); | |
740 | } | |
741 | graphics.fill(component.unusedSpaceCharacter); | |
742 | ||
743 | if(!component.isReadOnly()) { | |
744 | //Adjust caret position if necessary | |
745 | TerminalPosition caretPosition = component.getCaretPosition(); | |
746 | String caretLine = component.getLine(caretPosition.getRow()); | |
747 | caretPosition = caretPosition.withColumn(Math.min(caretPosition.getColumn(), caretLine.length())); | |
748 | ||
749 | //Adjust the view if necessary | |
750 | int trueColumnPosition = TerminalTextUtils.getColumnIndex(caretLine, caretPosition.getColumn()); | |
751 | if (trueColumnPosition < viewTopLeft.getColumn()) { | |
752 | viewTopLeft = viewTopLeft.withColumn(trueColumnPosition); | |
753 | } | |
754 | else if (trueColumnPosition >= textAreaSize.getColumns() + viewTopLeft.getColumn()) { | |
755 | viewTopLeft = viewTopLeft.withColumn(trueColumnPosition - textAreaSize.getColumns() + 1); | |
756 | } | |
757 | if (caretPosition.getRow() < viewTopLeft.getRow()) { | |
758 | viewTopLeft = viewTopLeft.withRow(caretPosition.getRow()); | |
759 | } | |
760 | else if (caretPosition.getRow() >= textAreaSize.getRows() + viewTopLeft.getRow()) { | |
761 | viewTopLeft = viewTopLeft.withRow(caretPosition.getRow() - textAreaSize.getRows() + 1); | |
762 | } | |
763 | ||
764 | //Additional corner-case for CJK characters | |
765 | if(trueColumnPosition - viewTopLeft.getColumn() == graphics.getSize().getColumns() - 1) { | |
766 | if(caretLine.length() > caretPosition.getColumn() && | |
767 | TerminalTextUtils.isCharCJK(caretLine.charAt(caretPosition.getColumn()))) { | |
768 | viewTopLeft = viewTopLeft.withRelativeColumn(1); | |
769 | } | |
770 | } | |
771 | } | |
772 | ||
773 | for (int row = 0; row < textAreaSize.getRows(); row++) { | |
774 | int rowIndex = row + viewTopLeft.getRow(); | |
775 | if(rowIndex >= component.lines.size()) { | |
776 | continue; | |
777 | } | |
778 | String line = component.lines.get(rowIndex); | |
779 | graphics.putString(0, row, TerminalTextUtils.fitString(line, viewTopLeft.getColumn(), textAreaSize.getColumns())); | |
780 | } | |
781 | } | |
782 | } | |
783 | } |