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