Performance improvement:
[jvcard.git] / src / be / nikiroo / jvcard / tui / MainWindow.java
1 package be.nikiroo.jvcard.tui;
2
3 import java.io.IOException;
4 import java.util.ArrayList;
5 import java.util.Arrays;
6 import java.util.LinkedList;
7 import java.util.List;
8
9 import be.nikiroo.jvcard.launcher.Main;
10 import be.nikiroo.jvcard.resources.StringUtils;
11 import be.nikiroo.jvcard.resources.enums.ColorOption;
12 import be.nikiroo.jvcard.resources.enums.StringId;
13 import be.nikiroo.jvcard.tui.KeyAction.Mode;
14 import be.nikiroo.jvcard.tui.panes.ContactDetails;
15 import be.nikiroo.jvcard.tui.panes.ContactDetailsRaw;
16 import be.nikiroo.jvcard.tui.panes.ContactList;
17 import be.nikiroo.jvcard.tui.panes.MainContent;
18
19 import com.googlecode.lanterna.TerminalSize;
20 import com.googlecode.lanterna.gui2.BasicWindow;
21 import com.googlecode.lanterna.gui2.BorderLayout;
22 import com.googlecode.lanterna.gui2.Direction;
23 import com.googlecode.lanterna.gui2.Interactable;
24 import com.googlecode.lanterna.gui2.Label;
25 import com.googlecode.lanterna.gui2.LinearLayout;
26 import com.googlecode.lanterna.gui2.Panel;
27 import com.googlecode.lanterna.gui2.TextBox;
28 import com.googlecode.lanterna.gui2.Window;
29 import com.googlecode.lanterna.input.KeyStroke;
30 import com.googlecode.lanterna.input.KeyType;
31
32 /**
33 * This is the main "window" of the program. It can show up to one
34 * {@link MainContent} at any one time, but keeps a stack of contents.
35 *
36 * @author niki
37 *
38 */
39
40 public class MainWindow extends BasicWindow {
41 private List<KeyAction> defaultActions = new LinkedList<KeyAction>();
42 private List<KeyAction> actions = new LinkedList<KeyAction>();
43 private List<MainContent> contentStack = new LinkedList<MainContent>();
44 private UserQuestion userQuestion;
45 private String titleCache;
46 private Panel titlePanel;
47 private Panel mainPanel;
48 private Panel contentPanel;
49 private Panel actionPanel;
50 private Panel messagePanel;
51 private TextBox text;
52
53 /**
54 * Information about a question to ask the user and its answer.
55 *
56 * @author niki
57 *
58 */
59 private class UserQuestion {
60 private boolean oneKeyAnswer;
61 private KeyAction action;
62 private String answer;
63
64 /**
65 * Create a new {@link UserQuestion}.
66 *
67 * @param action
68 * the action that triggered the question
69 * @param oneKeyAnswer
70 * TRUE if we expect a one-key answer
71 */
72 public UserQuestion(KeyAction action, boolean oneKeyAnswer) {
73 this.action = action;
74 this.oneKeyAnswer = oneKeyAnswer;
75 }
76
77 /**
78 * Return the {@link KeyAction} that triggered the question.
79 *
80 * @return the {@link KeyAction}
81 */
82 public KeyAction getAction() {
83 return action;
84 }
85
86 /**
87 * Check if a one-key answer is expected.
88 *
89 * @return TRUE if a one-key answer is expected
90 */
91 public boolean isOneKeyAnswer() {
92 return oneKeyAnswer;
93 }
94
95 /**
96 * Return the user answer.
97 *
98 * @return the user answer
99 */
100 public String getAnswer() {
101 return answer;
102 }
103
104 /**
105 * Set the user answer.
106 *
107 * @param answer
108 * the new answer
109 */
110 public void setAnswer(String answer) {
111 this.answer = answer;
112 }
113 }
114
115 /**
116 * Create a new, empty window.
117 */
118 public MainWindow() {
119 this(null);
120 }
121
122 /**
123 * Create a new window hosting the given content.
124 *
125 * @param content
126 * the content to host
127 */
128 public MainWindow(MainContent content) {
129 super(content == null ? "" : content.getTitle());
130
131 setHints(Arrays.asList(Window.Hint.FULL_SCREEN,
132 Window.Hint.NO_DECORATIONS, Window.Hint.FIT_TERMINAL_WINDOW));
133
134 defaultActions.add(new KeyAction(Mode.BACK, 'q',
135 StringId.KEY_ACTION_BACK));
136 defaultActions.add(new KeyAction(Mode.BACK, KeyType.Escape,
137 StringId.NULL));
138 defaultActions.add(new KeyAction(Mode.HELP, 'h',
139 StringId.KEY_ACTION_HELP));
140 defaultActions.add(new KeyAction(Mode.HELP, KeyType.F1, StringId.NULL));
141
142 actionPanel = new Panel();
143 contentPanel = new Panel();
144 mainPanel = new Panel();
145 messagePanel = new Panel();
146 titlePanel = new Panel();
147
148 Panel actionMessagePanel = new Panel();
149
150 LinearLayout llayout = new LinearLayout(Direction.HORIZONTAL);
151 llayout.setSpacing(0);
152 actionPanel.setLayoutManager(llayout);
153
154 BorderLayout blayout = new BorderLayout();
155 titlePanel.setLayoutManager(blayout);
156
157 llayout = new LinearLayout(Direction.VERTICAL);
158 llayout.setSpacing(0);
159 messagePanel.setLayoutManager(llayout);
160
161 blayout = new BorderLayout();
162 mainPanel.setLayoutManager(blayout);
163
164 blayout = new BorderLayout();
165 contentPanel.setLayoutManager(blayout);
166
167 blayout = new BorderLayout();
168 actionMessagePanel.setLayoutManager(blayout);
169
170 actionMessagePanel
171 .addComponent(messagePanel, BorderLayout.Location.TOP);
172 actionMessagePanel.addComponent(actionPanel,
173 BorderLayout.Location.CENTER);
174
175 mainPanel.addComponent(titlePanel, BorderLayout.Location.TOP);
176 mainPanel.addComponent(contentPanel, BorderLayout.Location.CENTER);
177 mainPanel
178 .addComponent(actionMessagePanel, BorderLayout.Location.BOTTOM);
179
180 pushContent(content);
181
182 setComponent(mainPanel);
183 }
184
185 /**
186 * "push" some content to the window stack.
187 *
188 * @param content
189 * the new top-of-the-stack content
190 */
191 public void pushContent(MainContent content) {
192 List<KeyAction> actions = null;
193
194 contentPanel.removeAllComponents();
195 if (content != null) {
196 actions = content.getKeyBindings();
197 contentPanel.addComponent(content, BorderLayout.Location.CENTER);
198 this.contentStack.add(content);
199
200 Interactable focus = content.nextFocus(null);
201 if (focus != null)
202 focus.takeFocus();
203 }
204
205 setTitle();
206 setActions(actions, true);
207 }
208
209 /**
210 * "pop" the latest, top-of-the-stack content from the window stack.
211 *
212 * @return the removed content if any
213 */
214 public MainContent popContent() {
215 MainContent removed = null;
216 MainContent prev = null;
217
218 MainContent content = getContent();
219 if (content != null)
220 removed = contentStack.remove(contentStack.size() - 1);
221
222 if (contentStack.size() > 0)
223 prev = contentStack.remove(contentStack.size() - 1);
224
225 if (prev != null) {
226 try {
227 String mess = prev.wakeup();
228 if (mess != null)
229 setMessage(mess, false);
230 } catch (IOException e) {
231 setMessage(e.getMessage(), true);
232 }
233 }
234
235 pushContent(prev);
236
237 return removed;
238 }
239
240 /**
241 * Show the given message on screen. It will disappear at the next action.
242 *
243 * @param mess
244 * the message to display
245 * @param error
246 * TRUE for an error message, FALSE for an information message
247 *
248 * @return TRUE if changes were performed
249 */
250 public boolean setMessage(String mess, boolean error) {
251 if (mess != null || messagePanel.getChildCount() > 0) {
252 messagePanel.removeAllComponents();
253 if (mess != null) {
254 ColorOption element = (error ? ColorOption.LINE_MESSAGE_ERR
255 : ColorOption.LINE_MESSAGE);
256 Label lbl = UiColors.createLabel(element, " " + mess + " ");
257 messagePanel.addComponent(lbl, LinearLayout
258 .createLayoutData(LinearLayout.Alignment.Center));
259 }
260 return true;
261 }
262
263 return false;
264 }
265
266 /**
267 * Show a question to the user and switch to "ask for answer" mode see
268 * {@link MainWindow#handleQuestion}. The user will be asked to enter some
269 * answer and confirm with ENTER.
270 *
271 * @param action
272 * the related action
273 * @param question
274 * the question to ask
275 * @param initial
276 * the initial answer if any (to be edited by the user)
277 */
278 public void setQuestion(KeyAction action, String question, String initial) {
279 setQuestion(action, question, initial, false);
280 }
281
282 /**
283 * Show a question to the user and switch to "ask for answer" mode see
284 * {@link MainWindow#handleQuestion}. The user will be asked to hit one key
285 * as an answer.
286 *
287 * @param action
288 * the related action
289 * @param question
290 * the question to ask
291 */
292 public void setQuestion(KeyAction action, String question) {
293 setQuestion(action, question, null, true);
294 }
295
296 /**
297 * Show a question to the user and switch to "ask for answer" mode see
298 * {@link MainWindow#handleQuestion}.
299 *
300 * @param action
301 * the related action
302 * @param question
303 * the question to ask
304 * @param initial
305 * the initial answer if any (to be edited by the user)
306 * @param oneKey
307 * TRUE for a one-key answer, FALSE for a text answer validated
308 * by ENTER
309 */
310 private void setQuestion(KeyAction action, String question, String initial,
311 boolean oneKey) {
312 userQuestion = new UserQuestion(action, oneKey);
313
314 messagePanel.removeAllComponents();
315
316 Panel hpanel = new Panel();
317 LinearLayout llayout = new LinearLayout(Direction.HORIZONTAL);
318 llayout.setSpacing(0);
319 hpanel.setLayoutManager(llayout);
320
321 Label lbl = UiColors.createLabel(ColorOption.LINE_MESSAGE_QUESTION, " "
322 + question + " ");
323 text = new TextBox(new TerminalSize(getSize().getColumns()
324 - lbl.getSize().getColumns(), 1));
325
326 if (initial != null) {
327 // add all chars one by one so the caret is at the end
328 for (int index = 0; index < initial.length(); index++) {
329 text.handleInput(new KeyStroke(initial.charAt(index), false,
330 false));
331 }
332 }
333
334 hpanel.addComponent(lbl,
335 LinearLayout.createLayoutData(LinearLayout.Alignment.Beginning));
336 hpanel.addComponent(text,
337 LinearLayout.createLayoutData(LinearLayout.Alignment.Fill));
338
339 messagePanel
340 .addComponent(hpanel, LinearLayout
341 .createLayoutData(LinearLayout.Alignment.Beginning));
342
343 text.takeFocus();
344 }
345
346 /**
347 * Refresh the window and the empty-space handling. You should call this
348 * method when the window size changed.
349 *
350 * @param size
351 * the new size of the window
352 */
353 public void refresh(TerminalSize size) {
354 if (size == null)
355 return;
356
357 if (getSize() == null || !getSize().equals(size))
358 setSize(size);
359
360 setTitle();
361
362 if (actions != null)
363 setActions(new ArrayList<KeyAction>(actions), false);
364
365 invalidate();
366 }
367
368 @Override
369 public void invalidate() {
370 super.invalidate();
371 for (MainContent content : contentStack) {
372 content.invalidate();
373 }
374 }
375
376 @Override
377 public boolean handleInput(KeyStroke key) {
378 boolean handled = false;
379
380 if (userQuestion != null) {
381 handled = handleQuestion(userQuestion, key);
382 if (handled) {
383 if (userQuestion.getAnswer() != null) {
384 handleAction(userQuestion.getAction(),
385 userQuestion.getAnswer());
386
387 userQuestion = null;
388 }
389 }
390 } else {
391 handled = handleKey(key);
392 }
393
394 if (!handled) {
395 handled = super.handleInput(key);
396 }
397
398 return handled;
399 }
400
401 /**
402 * Actually set the title <b>inside</b> the window. Will also call
403 * {@link BasicWindow#setTitle} with the computed parameters.
404 */
405 private void setTitle() {
406 String prefix = " " + Main.APPLICATION_TITLE + " (version "
407 + Main.APPLICATION_VERSION + ")";
408
409 String title = null;
410 int count = -1;
411
412 MainContent content = getContent();
413 if (content != null) {
414 title = content.getTitle();
415 count = content.getCount();
416 }
417
418 if (title == null)
419 title = "";
420
421 if (title.length() > 0) {
422 prefix = prefix + ": ";
423 title = StringUtils.sanitize(title, Main.isUnicode());
424 }
425
426 String countStr = "";
427 if (count > -1) {
428 countStr = "[" + count + "]";
429 }
430
431 int width = -1;
432 if (getSize() != null) {
433 width = getSize().getColumns();
434 }
435
436 if (width > 0) {
437 int padding = width - prefix.length() - title.length()
438 - countStr.length();
439 if (padding > 0) {
440 if (title.length() > 0)
441 title = StringUtils.padString(title, title.length()
442 + padding);
443 else
444 prefix = StringUtils.padString(prefix, prefix.length()
445 + padding);
446 }
447 }
448
449 String titleCache = prefix + title + count;
450 if (!titleCache.equals(this.titleCache)) {
451 super.setTitle(prefix);
452
453 Label lblPrefix = new Label(prefix);
454 UiColors.themeLabel(ColorOption.TITLE_MAIN, lblPrefix);
455
456 Label lblTitle = null;
457 if (title.length() > 0) {
458 lblTitle = new Label(title);
459 UiColors.themeLabel(ColorOption.TITLE_VARIABLE, lblTitle);
460 }
461
462 Label lblCount = null;
463 if (countStr != null) {
464 lblCount = new Label(countStr);
465 UiColors.themeLabel(ColorOption.TITLE_COUNT, lblCount);
466 }
467
468 titlePanel.removeAllComponents();
469
470 titlePanel.addComponent(lblPrefix, BorderLayout.Location.LEFT);
471 if (lblTitle != null)
472 titlePanel.addComponent(lblTitle, BorderLayout.Location.CENTER);
473 if (lblCount != null)
474 titlePanel.addComponent(lblCount, BorderLayout.Location.RIGHT);
475 }
476 }
477
478 /**
479 * Return the current {@link MainContent} from the stack if any.
480 *
481 * @return the current {@link MainContent}
482 */
483 private MainContent getContent() {
484 if (contentStack.size() > 0) {
485 return contentStack.get(contentStack.size() - 1);
486 }
487
488 return null;
489 }
490
491 /**
492 * Update the list of actions and refresh the action panel.
493 *
494 * @param actions
495 * the list of actions to support
496 * @param enableDefaultactions
497 * TRUE to enable the default actions
498 */
499 private void setActions(List<KeyAction> actions,
500 boolean enableDefaultactions) {
501 this.actions.clear();
502
503 if (enableDefaultactions)
504 this.actions.addAll(defaultActions);
505
506 if (actions != null)
507 this.actions.addAll(actions);
508
509 actionPanel.removeAllComponents();
510 for (KeyAction action : this.actions) {
511 String trans = " " + Main.trans(action.getStringId()) + " ";
512
513 if (" ".equals(trans))
514 continue;
515
516 String keyTrans = KeyAction.trans(action.getKey());
517
518 Panel kPane = new Panel();
519 LinearLayout layout = new LinearLayout(Direction.HORIZONTAL);
520 layout.setSpacing(0);
521 kPane.setLayoutManager(layout);
522
523 kPane.addComponent(UiColors.createLabel(ColorOption.ACTION_KEY,
524 keyTrans));
525 kPane.addComponent(UiColors.createLabel(ColorOption.ACTION_DESC,
526 trans));
527
528 actionPanel.addComponent(kPane);
529 }
530
531 // fill with "desc" colour
532 int width = -1;
533 if (getSize() != null) {
534 width = getSize().getColumns();
535 }
536
537 if (width > 0) {
538 actionPanel.addComponent(UiColors.createLabel(
539 ColorOption.ACTION_DESC, StringUtils.padString("", width)));
540 }
541 }
542
543 /**
544 * Handle user input when in "ask for question" mode (see
545 * {@link MainWindow#userQuestion}).
546 *
547 * @param userQuestion
548 * the question data
549 * @param key
550 * the key that has been pressed by the user
551 *
552 * @return TRUE if the {@link KeyStroke} was handled
553 */
554 private boolean handleQuestion(UserQuestion userQuestion, KeyStroke key) {
555 userQuestion.setAnswer(null);
556
557 if (userQuestion.isOneKeyAnswer()) {
558 userQuestion.setAnswer("" + key.getCharacter());
559 } else {
560 // ^h == Backspace
561 if (key.isCtrlDown() && key.getCharacter() == 'h') {
562 key = new KeyStroke(KeyType.Backspace);
563 }
564
565 switch (key.getKeyType()) {
566 case Enter:
567 if (text != null)
568 userQuestion.setAnswer(text.getText());
569 else
570 userQuestion.setAnswer("");
571 break;
572 case Backspace:
573 int pos = text.getCaretPosition().getColumn();
574 if (pos > 0) {
575 String current = text.getText();
576 // force caret one space before:
577 text.setText(current.substring(0, pos - 1));
578 // re-add full text:
579 text.setText(current.substring(0, pos - 1)
580 + current.substring(pos));
581 }
582 return true;
583 default:
584 // Do nothing (continue entering text)
585 break;
586 }
587 }
588
589 if (userQuestion.getAnswer() != null) {
590 Interactable focus = null;
591 MainContent content = getContent();
592 if (content != null)
593 focus = content.nextFocus(null);
594
595 focus.takeFocus();
596
597 return true;
598 }
599
600 return false;
601 }
602
603 /**
604 * Handle the input in case of "normal" (not "ask for answer") mode.
605 *
606 * @param key
607 * the key that was pressed
608 * @param answer
609 * the answer given for this key
610 *
611 * @return if the window handled the input
612 */
613 private boolean handleKey(KeyStroke key) {
614 boolean handled = false;
615
616 if (setMessage(null, false))
617 return true;
618
619 for (KeyAction action : actions) {
620 if (!action.match(key))
621 continue;
622
623 handled = true;
624
625 action.getObject(); // see {@link KeyAction#getMessage()}
626 String mess = action.getMessage();
627 if (mess != null) {
628 setMessage(mess, action.isError());
629 }
630
631 if (!action.isError() && action.onAction()) {
632 handleAction(action, null);
633 }
634
635 break;
636 }
637
638 return handled;
639 }
640
641 /**
642 * Handle the input in case of "normal" (not "ask for answer") mode.
643 *
644 * @param key
645 * the key that was pressed
646 * @param answer
647 * the answer given for this key
648 *
649 * @return if the window handled the input
650 */
651 private void handleAction(KeyAction action, String answer) {
652 MainContent content = getContent();
653
654 switch (action.getMode()) {
655 case MOVE:
656 int x = 0;
657 int y = 0;
658
659 if (action.getKey().getKeyType() == KeyType.ArrowUp)
660 x = -1;
661 if (action.getKey().getKeyType() == KeyType.ArrowDown)
662 x = 1;
663 if (action.getKey().getKeyType() == KeyType.ArrowLeft)
664 y = -1;
665 if (action.getKey().getKeyType() == KeyType.ArrowRight)
666 y = 1;
667
668 if (content != null) {
669 String err = content.move(x, y);
670 if (err != null)
671 setMessage(err, true);
672 }
673
674 break;
675 // mode with windows:
676 case CONTACT_LIST:
677 if (action.getCard() != null) {
678 pushContent(new ContactList(action.getCard()));
679 } else if (action.getObject() != null
680 && action.getObject() instanceof MainContent) {
681 MainContent mergeContent = (MainContent) action.getObject();
682 pushContent(mergeContent);
683 }
684 break;
685 case CONTACT_DETAILS:
686 if (action.getContact() != null) {
687 pushContent(new ContactDetails(action.getContact()));
688 }
689 break;
690 case CONTACT_DETAILS_RAW:
691 if (action.getContact() != null) {
692 pushContent(new ContactDetailsRaw(action.getContact()));
693 }
694 break;
695 // mode interpreted by MainWindow:
696 case HELP:
697 // TODO
698 setMessage("Help! I need somebody! Help!", false);
699
700 break;
701 case BACK:
702 String warning = content.getExitWarning();
703 if (warning != null) {
704 if (answer == null) {
705 setQuestion(action, warning);
706 } else {
707 setMessage(null, false);
708 if (answer.equalsIgnoreCase("y")) {
709 popContent();
710 }
711 }
712 } else {
713 popContent();
714 }
715
716 if (contentStack.size() == 0) {
717 close();
718 }
719
720 break;
721 // action modes:
722 case ASK_USER:
723 if (answer == null) {
724 setQuestion(action, action.getQuestion(),
725 action.getDefaultAnswer());
726 } else {
727 setMessage(action.callback(answer), true);
728 content.refreshData();
729 invalidate();
730 setTitle();
731 }
732 break;
733 case ASK_USER_KEY:
734 if (answer == null) {
735 setQuestion(action, action.getQuestion());
736 } else {
737 setMessage(action.callback(answer), true);
738 content.refreshData();
739 invalidate();
740 setTitle();
741 }
742 break;
743 default:
744 case NONE:
745 break;
746 }
747 }
748 }