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