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