Merge branch 'subtree'
[fanfix.git] / src / jexer / TTerminalWidget.java
1 /*
2 * Jexer - Java Text User Interface
3 *
4 * The MIT License (MIT)
5 *
6 * Copyright (C) 2019 Kevin Lamonte
7 *
8 * Permission is hereby granted, free of charge, to any person obtaining a
9 * copy of this software and associated documentation files (the "Software"),
10 * to deal in the Software without restriction, including without limitation
11 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
12 * and/or sell copies of the Software, and to permit persons to whom the
13 * Software is furnished to do so, subject to the following conditions:
14 *
15 * The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
17 *
18 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
21 * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
24 * DEALINGS IN THE SOFTWARE.
25 *
26 * @author Kevin Lamonte [kevin.lamonte@gmail.com]
27 * @version 1
28 */
29 package jexer;
30
31 import java.awt.Graphics2D;
32 import java.awt.image.BufferedImage;
33 import java.io.File;
34 import java.io.IOException;
35 import java.lang.reflect.Field;
36 import java.text.MessageFormat;
37 import java.util.List;
38 import java.util.Map;
39 import java.util.ResourceBundle;
40
41 import jexer.backend.ECMA48Terminal;
42 import jexer.backend.GlyphMaker;
43 import jexer.backend.SwingTerminal;
44 import jexer.bits.Cell;
45 import jexer.event.TCommandEvent;
46 import jexer.event.TKeypressEvent;
47 import jexer.event.TMenuEvent;
48 import jexer.event.TMouseEvent;
49 import jexer.event.TResizeEvent;
50 import jexer.menu.TMenu;
51 import jexer.tterminal.DisplayLine;
52 import jexer.tterminal.DisplayListener;
53 import jexer.tterminal.ECMA48;
54 import static jexer.TCommand.*;
55 import static jexer.TKeypress.*;
56
57 /**
58 * TTerminalWidget exposes a ECMA-48 / ANSI X3.64 style terminal in a widget.
59 */
60 public class TTerminalWidget extends TScrollableWidget
61 implements DisplayListener, EditMenuUser {
62
63 /**
64 * Translated strings.
65 */
66 private static final ResourceBundle i18n = ResourceBundle.getBundle(TTerminalWidget.class.getName());
67
68 // ------------------------------------------------------------------------
69 // Variables --------------------------------------------------------------
70 // ------------------------------------------------------------------------
71
72 /**
73 * The emulator.
74 */
75 private ECMA48 emulator;
76
77 /**
78 * The Process created by the shell spawning constructor.
79 */
80 private Process shell;
81
82 /**
83 * If true, something called 'ptypipe' is on the PATH and executable.
84 */
85 private static boolean ptypipeOnPath = false;
86
87 /**
88 * If true, we are using the ptypipe utility to support dynamic window
89 * resizing. ptypipe is available at
90 * https://gitlab.com/klamonte/ptypipe .
91 */
92 private boolean ptypipe = false;
93
94 /**
95 * Double-height font.
96 */
97 private GlyphMaker doubleFont;
98
99 /**
100 * Last text width value.
101 */
102 private int lastTextWidth = -1;
103
104 /**
105 * Last text height value.
106 */
107 private int lastTextHeight = -1;
108
109 /**
110 * The blink state, used only by ECMA48 backend and when double-width
111 * chars must be drawn.
112 */
113 private boolean blinkState = true;
114
115 /**
116 * Timer flag, used only by ECMA48 backend and when double-width chars
117 * must be drawn.
118 */
119 private boolean haveTimer = false;
120
121 /**
122 * The last seen visible display.
123 */
124 private List<DisplayLine> display;
125
126 /**
127 * If true, the display has changed and needs updating.
128 */
129 private volatile boolean dirty = true;
130
131 /**
132 * Time that the display was last updated.
133 */
134 private long lastUpdateTime = 0;
135
136 /**
137 * If true, hide the mouse after typing a keystroke.
138 */
139 private boolean hideMouseWhenTyping = true;
140
141 /**
142 * If true, the mouse should not be displayed because a keystroke was
143 * typed.
144 */
145 private boolean typingHidMouse = false;
146
147 /**
148 * The return value from the emulator.
149 */
150 private int exitValue = -1;
151
152 /**
153 * Title to expose to a window.
154 */
155 private String title = "";
156
157 /**
158 * Action to perform when the terminal exits.
159 */
160 private TAction closeAction = null;
161
162 // ------------------------------------------------------------------------
163 // Constructors -----------------------------------------------------------
164 // ------------------------------------------------------------------------
165
166 /**
167 * Static constructor.
168 */
169 static {
170 checkForPtypipe();
171 }
172
173 /**
174 * Public constructor spawns a custom command line.
175 *
176 * @param parent parent widget
177 * @param x column relative to parent
178 * @param y row relative to parent
179 * @param commandLine the command line to execute
180 */
181 public TTerminalWidget(final TWidget parent, final int x, final int y,
182 final String commandLine) {
183
184 this(parent, x, y, commandLine.split("\\s+"));
185 }
186
187 /**
188 * Public constructor spawns a custom command line.
189 *
190 * @param parent parent widget
191 * @param x column relative to parent
192 * @param y row relative to parent
193 * @param command the command line to execute
194 */
195 public TTerminalWidget(final TWidget parent, final int x, final int y,
196 final String [] command) {
197
198 this(parent, x, y, command, null);
199 }
200
201 /**
202 * Public constructor spawns a custom command line.
203 *
204 * @param parent parent widget
205 * @param x column relative to parent
206 * @param y row relative to parent
207 * @param command the command line to execute
208 * @param closeAction action to perform when the shell exits
209 */
210 public TTerminalWidget(final TWidget parent, final int x, final int y,
211 final String [] command, final TAction closeAction) {
212
213 this(parent, x, y, 80, 24, command, closeAction);
214 }
215
216 /**
217 * Public constructor spawns a custom command line.
218 *
219 * @param parent parent widget
220 * @param x column relative to parent
221 * @param y row relative to parent
222 * @param width width of widget
223 * @param height height of widget
224 * @param command the command line to execute
225 * @param closeAction action to perform when the shell exits
226 */
227 public TTerminalWidget(final TWidget parent, final int x, final int y,
228 final int width, final int height, final String [] command,
229 final TAction closeAction) {
230
231 super(parent, x, y, width, height);
232
233 this.closeAction = closeAction;
234
235 String [] fullCommand;
236
237 // Spawn a shell and pass its I/O to the other constructor.
238 if ((System.getProperty("jexer.TTerminal.ptypipe") != null)
239 && (System.getProperty("jexer.TTerminal.ptypipe").
240 equals("true"))
241 ) {
242 ptypipe = true;
243 fullCommand = new String[command.length + 1];
244 fullCommand[0] = "ptypipe";
245 System.arraycopy(command, 0, fullCommand, 1, command.length);
246 } else if (System.getProperty("jexer.TTerminal.ptypipe",
247 "auto").equals("auto")
248 && (ptypipeOnPath == true)
249 ) {
250 ptypipe = true;
251 fullCommand = new String[command.length + 1];
252 fullCommand[0] = "ptypipe";
253 System.arraycopy(command, 0, fullCommand, 1, command.length);
254 } else if (System.getProperty("os.name").startsWith("Windows")) {
255 fullCommand = new String[3];
256 fullCommand[0] = "cmd";
257 fullCommand[1] = "/c";
258 fullCommand[2] = stringArrayToString(command);
259 } else if (System.getProperty("os.name").startsWith("Mac")) {
260 fullCommand = new String[6];
261 fullCommand[0] = "script";
262 fullCommand[1] = "-q";
263 fullCommand[2] = "-F";
264 fullCommand[3] = "/dev/null";
265 fullCommand[4] = "-c";
266 fullCommand[5] = stringArrayToString(command);
267 } else {
268 // Default: behave like Linux
269 if (System.getProperty("jexer.TTerminal.setsid",
270 "true").equals("false")
271 ) {
272 fullCommand = new String[5];
273 fullCommand[0] = "script";
274 fullCommand[1] = "-fqe";
275 fullCommand[2] = "/dev/null";
276 fullCommand[3] = "-c";
277 fullCommand[4] = stringArrayToString(command);
278 } else {
279 fullCommand = new String[6];
280 fullCommand[0] = "setsid";
281 fullCommand[1] = "script";
282 fullCommand[2] = "-fqe";
283 fullCommand[3] = "/dev/null";
284 fullCommand[4] = "-c";
285 fullCommand[5] = stringArrayToString(command);
286 }
287 }
288 spawnShell(fullCommand);
289 }
290
291 /**
292 * Public constructor spawns a shell.
293 *
294 * @param parent parent widget
295 * @param x column relative to parent
296 * @param y row relative to parent
297 */
298 public TTerminalWidget(final TWidget parent, final int x, final int y) {
299 this(parent, x, y, (TAction) null);
300 }
301
302 /**
303 * Public constructor spawns a shell.
304 *
305 * @param parent parent widget
306 * @param x column relative to parent
307 * @param y row relative to parent
308 * @param closeAction action to perform when the shell exits
309 */
310 public TTerminalWidget(final TWidget parent, final int x, final int y,
311 final TAction closeAction) {
312
313 this(parent, x, y, 80, 24, closeAction);
314 }
315
316 /**
317 * Public constructor spawns a shell.
318 *
319 * @param parent parent widget
320 * @param x column relative to parent
321 * @param y row relative to parent
322 * @param width width of widget
323 * @param height height of widget
324 * @param closeAction action to perform when the shell exits
325 */
326 public TTerminalWidget(final TWidget parent, final int x, final int y,
327 final int width, final int height, final TAction closeAction) {
328
329 super(parent, x, y, width, height);
330
331 this.closeAction = closeAction;
332
333 if (System.getProperty("jexer.TTerminal.shell") != null) {
334 String shell = System.getProperty("jexer.TTerminal.shell");
335 if (shell.trim().startsWith("ptypipe")) {
336 ptypipe = true;
337 }
338 spawnShell(shell.split("\\s+"));
339 return;
340 }
341
342 String cmdShellWindows = "cmd.exe";
343
344 // You cannot run a login shell in a bare Process interactively, due
345 // to libc's behavior of buffering when stdin/stdout aren't a tty.
346 // Use 'script' instead to run a shell in a pty. And because BSD and
347 // GNU differ on the '-f' vs '-F' flags, we need two different
348 // commands. Lovely.
349 String cmdShellGNU = "script -fqe /dev/null";
350 String cmdShellGNUSetsid = "setsid script -fqe /dev/null";
351 String cmdShellBSD = "script -q -F /dev/null";
352
353 // ptypipe is another solution that permits dynamic window resizing.
354 String cmdShellPtypipe = "ptypipe /bin/bash --login";
355
356 // Spawn a shell and pass its I/O to the other constructor.
357 if ((System.getProperty("jexer.TTerminal.ptypipe") != null)
358 && (System.getProperty("jexer.TTerminal.ptypipe").
359 equals("true"))
360 ) {
361 ptypipe = true;
362 spawnShell(cmdShellPtypipe.split("\\s+"));
363 } else if (System.getProperty("jexer.TTerminal.ptypipe",
364 "auto").equals("auto")
365 && (ptypipeOnPath == true)
366 ) {
367 ptypipe = true;
368 spawnShell(cmdShellPtypipe.split("\\s+"));
369 } else if (System.getProperty("os.name").startsWith("Windows")) {
370 spawnShell(cmdShellWindows.split("\\s+"));
371 } else if (System.getProperty("os.name").startsWith("Mac")) {
372 spawnShell(cmdShellBSD.split("\\s+"));
373 } else if (System.getProperty("os.name").startsWith("Linux")) {
374 if (System.getProperty("jexer.TTerminal.setsid",
375 "true").equals("false")
376 ) {
377 spawnShell(cmdShellGNU.split("\\s+"));
378 } else {
379 spawnShell(cmdShellGNUSetsid.split("\\s+"));
380 }
381 } else {
382 // When all else fails, assume GNU.
383 spawnShell(cmdShellGNU.split("\\s+"));
384 }
385 }
386
387 // ------------------------------------------------------------------------
388 // Event handlers ---------------------------------------------------------
389 // ------------------------------------------------------------------------
390
391 /**
392 * Handle window/screen resize events.
393 *
394 * @param resize resize event
395 */
396 @Override
397 public void onResize(final TResizeEvent resize) {
398 // Let TWidget set my size.
399 super.onResize(resize);
400
401 if (emulator == null) {
402 return;
403 }
404
405 // Synchronize against the emulator so we don't stomp on its reader
406 // thread.
407 synchronized (emulator) {
408
409 if (resize.getType() == TResizeEvent.Type.WIDGET) {
410 // Resize the scroll bars
411 reflowData();
412 placeScrollbars();
413
414 // Get out of scrollback
415 setVerticalValue(0);
416
417 if (ptypipe) {
418 emulator.setWidth(getWidth());
419 emulator.setHeight(getHeight());
420
421 emulator.writeRemote("\033[8;" + getHeight() + ";" +
422 getWidth() + "t");
423 }
424
425 // Pass the correct text cell width/height to the emulator
426 if (getScreen() != null) {
427 emulator.setTextWidth(getScreen().getTextWidth());
428 emulator.setTextHeight(getScreen().getTextHeight());
429 }
430 }
431 return;
432
433 } // synchronized (emulator)
434 }
435
436 /**
437 * Handle keystrokes.
438 *
439 * @param keypress keystroke event
440 */
441 @Override
442 public void onKeypress(final TKeypressEvent keypress) {
443 if (hideMouseWhenTyping) {
444 typingHidMouse = true;
445 }
446
447 // Scrollback up/down
448 if (keypress.equals(kbShiftPgUp)
449 || keypress.equals(kbCtrlPgUp)
450 || keypress.equals(kbAltPgUp)
451 ) {
452 bigVerticalDecrement();
453 dirty = true;
454 return;
455 }
456 if (keypress.equals(kbShiftPgDn)
457 || keypress.equals(kbCtrlPgDn)
458 || keypress.equals(kbAltPgDn)
459 ) {
460 bigVerticalIncrement();
461 dirty = true;
462 return;
463 }
464
465 if ((emulator != null) && (emulator.isReading())) {
466 // Get out of scrollback
467 setVerticalValue(0);
468 emulator.addUserEvent(keypress);
469
470 // UGLY HACK TIME! cmd.exe needs CRLF, not just CR, so if
471 // this is kBEnter then also send kbCtrlJ.
472 if (keypress.equals(kbEnter)) {
473 if (System.getProperty("os.name").startsWith("Windows")
474 && (System.getProperty("jexer.TTerminal.cmdHack",
475 "true").equals("true"))
476 ) {
477 emulator.addUserEvent(new TKeypressEvent(kbCtrlJ));
478 }
479 }
480
481 readEmulatorState();
482 return;
483 }
484
485 // Process is closed, honor "normal" TUI keystrokes
486 super.onKeypress(keypress);
487 }
488
489 /**
490 * Handle mouse press events.
491 *
492 * @param mouse mouse button press event
493 */
494 @Override
495 public void onMouseDown(final TMouseEvent mouse) {
496 if (hideMouseWhenTyping) {
497 typingHidMouse = false;
498 }
499
500 if (emulator != null) {
501 // If the emulator is tracking mouse buttons, it needs to see
502 // wheel events.
503 if (emulator.getMouseProtocol() == ECMA48.MouseProtocol.OFF) {
504 if (mouse.isMouseWheelUp()) {
505 verticalDecrement();
506 dirty = true;
507 return;
508 }
509 if (mouse.isMouseWheelDown()) {
510 verticalIncrement();
511 dirty = true;
512 return;
513 }
514 }
515 if (mouseOnEmulator(mouse)) {
516 emulator.addUserEvent(mouse);
517 readEmulatorState();
518 return;
519 }
520 }
521
522 // Emulator didn't consume it, pass it on
523 super.onMouseDown(mouse);
524 }
525
526 /**
527 * Handle mouse release events.
528 *
529 * @param mouse mouse button release event
530 */
531 @Override
532 public void onMouseUp(final TMouseEvent mouse) {
533 if (hideMouseWhenTyping) {
534 typingHidMouse = false;
535 }
536
537 if ((emulator != null) && (mouseOnEmulator(mouse))) {
538 emulator.addUserEvent(mouse);
539 readEmulatorState();
540 return;
541 }
542
543 // Emulator didn't consume it, pass it on
544 super.onMouseUp(mouse);
545 }
546
547 /**
548 * Handle mouse motion events.
549 *
550 * @param mouse mouse motion event
551 */
552 @Override
553 public void onMouseMotion(final TMouseEvent mouse) {
554 if (hideMouseWhenTyping) {
555 typingHidMouse = false;
556 }
557
558 if ((emulator != null) && (mouseOnEmulator(mouse))) {
559 emulator.addUserEvent(mouse);
560 readEmulatorState();
561 return;
562 }
563
564 // Emulator didn't consume it, pass it on
565 super.onMouseMotion(mouse);
566 }
567
568 /**
569 * Handle posted command events.
570 *
571 * @param command command event
572 */
573 @Override
574 public void onCommand(final TCommandEvent command) {
575 if (emulator == null) {
576 return;
577 }
578
579 if (command.equals(cmPaste)) {
580 // Paste text from clipboard.
581 String text = getClipboard().pasteText();
582 if (text != null) {
583 for (int i = 0; i < text.length(); ) {
584 int ch = text.codePointAt(i);
585 emulator.addUserEvent(new TKeypressEvent(false, 0, ch,
586 false, false, false));
587 i += Character.charCount(ch);
588 }
589 }
590 return;
591 }
592 }
593
594 // ------------------------------------------------------------------------
595 // TScrollableWidget ------------------------------------------------------
596 // ------------------------------------------------------------------------
597
598 /**
599 * Draw the display buffer.
600 */
601 @Override
602 public void draw() {
603 if (emulator == null) {
604 return;
605 }
606
607 int width = getDisplayWidth();
608
609 boolean syncEmulator = false;
610 if (System.currentTimeMillis() - lastUpdateTime >= 50) {
611 // Too much time has passed, draw it all.
612 syncEmulator = true;
613 } else if (emulator.isReading() && (dirty == false)) {
614 // Wait until the emulator has brought more data in.
615 syncEmulator = false;
616 } else if (!emulator.isReading() && (dirty == true)) {
617 // The emulator won't receive more data, update the display.
618 syncEmulator = true;
619 }
620
621 if ((syncEmulator == true)
622 || (display == null)
623 ) {
624 // We want to minimize the amount of time we have the emulator
625 // locked. Grab a copy of its display.
626 synchronized (emulator) {
627 // Update the scroll bars
628 reflowData();
629
630 if (!isDrawable()) {
631 // We lost the connection, onShellExit() called an action
632 // that ultimately removed this widget from the UI
633 // hierarchy, so no one cares if we update the display.
634 // Bail out.
635 return;
636 }
637
638 if ((display == null) || emulator.isReading()) {
639 display = emulator.getVisibleDisplay(getHeight(),
640 -getVerticalValue());
641 assert (display.size() == getHeight());
642 }
643 width = emulator.getWidth();
644 }
645 dirty = false;
646 }
647
648 // Now draw the emulator screen
649 int row = 0;
650 for (DisplayLine line: display) {
651 int widthMax = width;
652 if (line.isDoubleWidth()) {
653 widthMax /= 2;
654 }
655 if (widthMax > getWidth()) {
656 widthMax = getWidth();
657 }
658 for (int i = 0; i < widthMax; i++) {
659 Cell ch = line.charAt(i);
660
661 if (ch.isImage()) {
662 putCharXY(i, row, ch);
663 continue;
664 }
665
666 Cell newCell = new Cell(ch);
667 boolean reverse = line.isReverseColor() ^ ch.isReverse();
668 newCell.setReverse(false);
669 if (reverse) {
670 if (ch.getForeColorRGB() < 0) {
671 newCell.setBackColor(ch.getForeColor());
672 newCell.setBackColorRGB(-1);
673 } else {
674 newCell.setBackColorRGB(ch.getForeColorRGB());
675 }
676 if (ch.getBackColorRGB() < 0) {
677 newCell.setForeColor(ch.getBackColor());
678 newCell.setForeColorRGB(-1);
679 } else {
680 newCell.setForeColorRGB(ch.getBackColorRGB());
681 }
682 }
683 if (line.isDoubleWidth()) {
684 putDoubleWidthCharXY(line, (i * 2), row, newCell);
685 } else {
686 putCharXY(i, row, newCell);
687 }
688 }
689 row++;
690 }
691 }
692
693 /**
694 * Set current value of the vertical scroll.
695 *
696 * @param value the new scroll value
697 */
698 @Override
699 public void setVerticalValue(final int value) {
700 super.setVerticalValue(value);
701 dirty = true;
702 }
703
704 /**
705 * Perform a small step change up.
706 */
707 @Override
708 public void verticalDecrement() {
709 super.verticalDecrement();
710 dirty = true;
711 }
712
713 /**
714 * Perform a small step change down.
715 */
716 @Override
717 public void verticalIncrement() {
718 super.verticalIncrement();
719 dirty = true;
720 }
721
722 /**
723 * Perform a big step change up.
724 */
725 public void bigVerticalDecrement() {
726 super.bigVerticalDecrement();
727 dirty = true;
728 }
729
730 /**
731 * Perform a big step change down.
732 */
733 public void bigVerticalIncrement() {
734 super.bigVerticalIncrement();
735 dirty = true;
736 }
737
738 /**
739 * Go to the top edge of the vertical scroller.
740 */
741 public void toTop() {
742 super.toTop();
743 dirty = true;
744 }
745
746 /**
747 * Go to the bottom edge of the vertical scroller.
748 */
749 public void toBottom() {
750 super.toBottom();
751 dirty = true;
752 }
753
754 /**
755 * Handle widget close.
756 */
757 @Override
758 public void close() {
759 if (emulator != null) {
760 emulator.close();
761 }
762 if (shell != null) {
763 terminateShellChildProcess();
764 shell.destroy();
765 shell = null;
766 }
767 }
768
769 /**
770 * Resize scrollbars for a new width/height.
771 */
772 @Override
773 public void reflowData() {
774 if (emulator == null) {
775 return;
776 }
777
778 // Synchronize against the emulator so we don't stomp on its reader
779 // thread.
780 synchronized (emulator) {
781
782 // Pull cursor information
783 readEmulatorState();
784
785 // Vertical scrollbar
786 setTopValue(getHeight()
787 - (emulator.getScrollbackBuffer().size()
788 + emulator.getDisplayBuffer().size()));
789 setVerticalBigChange(getHeight());
790
791 } // synchronized (emulator)
792 }
793
794 // ------------------------------------------------------------------------
795 // TTerminalWidget --------------------------------------------------------
796 // ------------------------------------------------------------------------
797
798 /**
799 * Check for 'ptypipe' on the path. If available, set ptypipeOnPath.
800 */
801 private static void checkForPtypipe() {
802 String systemPath = System.getenv("PATH");
803 if (systemPath == null) {
804 return;
805 }
806
807 String [] paths = systemPath.split(File.pathSeparator);
808 if (paths == null) {
809 return;
810 }
811 if (paths.length == 0) {
812 return;
813 }
814 for (int i = 0; i < paths.length; i++) {
815 File path = new File(paths[i]);
816 if (path.exists() && path.isDirectory()) {
817 File [] files = path.listFiles();
818 if (files == null) {
819 continue;
820 }
821 if (files.length == 0) {
822 continue;
823 }
824 for (int j = 0; j < files.length; j++) {
825 File file = files[j];
826 if (file.canExecute() && file.getName().equals("ptypipe")) {
827 ptypipeOnPath = true;
828 return;
829 }
830 }
831 }
832 }
833 }
834
835 /**
836 * Get the desired window title.
837 *
838 * @return the title
839 */
840 public String getTitle() {
841 return title;
842 }
843
844 /**
845 * Returns true if this widget does not want the application-wide mouse
846 * cursor drawn over it.
847 *
848 * @return true if this widget does not want the application-wide mouse
849 * cursor drawn over it
850 */
851 public boolean hasHiddenMouse() {
852 if (emulator == null) {
853 return false;
854 }
855 return (emulator.hasHiddenMousePointer() || typingHidMouse);
856 }
857
858 /**
859 * See if the terminal is still running.
860 *
861 * @return if true, we are still connected to / reading from the remote
862 * side
863 */
864 public boolean isReading() {
865 if (emulator == null) {
866 return false;
867 }
868 return emulator.isReading();
869 }
870
871 /**
872 * Convert a string array to a whitespace-separated string.
873 *
874 * @param array the string array
875 * @return a single string
876 */
877 private String stringArrayToString(final String [] array) {
878 StringBuilder sb = new StringBuilder(array[0].length());
879 for (int i = 0; i < array.length; i++) {
880 sb.append(array[i]);
881 if (i < array.length - 1) {
882 sb.append(' ');
883 }
884 }
885 return sb.toString();
886 }
887
888 /**
889 * Spawn the shell.
890 *
891 * @param command the command line to execute
892 */
893 private void spawnShell(final String [] command) {
894
895 /*
896 System.err.printf("spawnShell(): '%s'\n",
897 stringArrayToString(command));
898 */
899
900 // We will have vScroller for its data fields and mouse event
901 // handling, but do not want to draw it.
902 vScroller = new TVScroller(null, getWidth(), 0, getHeight());
903 vScroller.setVisible(false);
904 setBottomValue(0);
905
906 title = i18n.getString("windowTitle");
907
908 // Assume XTERM
909 ECMA48.DeviceType deviceType = ECMA48.DeviceType.XTERM;
910
911 try {
912 ProcessBuilder pb = new ProcessBuilder(command);
913 Map<String, String> env = pb.environment();
914 env.put("TERM", ECMA48.deviceTypeTerm(deviceType));
915 env.put("LANG", ECMA48.deviceTypeLang(deviceType, "en"));
916 env.put("COLUMNS", "80");
917 env.put("LINES", "24");
918 pb.redirectErrorStream(true);
919 shell = pb.start();
920 emulator = new ECMA48(deviceType, shell.getInputStream(),
921 shell.getOutputStream(), this);
922 } catch (IOException e) {
923 messageBox(i18n.getString("errorLaunchingShellTitle"),
924 MessageFormat.format(i18n.getString("errorLaunchingShellText"),
925 e.getMessage()));
926 }
927
928 // Setup the scroll bars
929 onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
930 getHeight()));
931
932 // Hide mouse when typing option
933 if (System.getProperty("jexer.TTerminal.hideMouseWhenTyping",
934 "true").equals("false")) {
935
936 hideMouseWhenTyping = false;
937 }
938 }
939
940 /**
941 * Terminate the child of the 'script' process used on POSIX. This may
942 * or may not work.
943 */
944 private void terminateShellChildProcess() {
945 int pid = -1;
946 if (shell.getClass().getName().equals("java.lang.UNIXProcess")) {
947 /* get the PID on unix/linux systems */
948 try {
949 Field field = shell.getClass().getDeclaredField("pid");
950 field.setAccessible(true);
951 pid = field.getInt(shell);
952 } catch (Throwable e) {
953 // SQUASH, this didn't work. Just bail out quietly.
954 return;
955 }
956 }
957 if (pid != -1) {
958 // shell.destroy() works successfully at killing this side of
959 // 'script'. But we need to make sure the other side (child
960 // process) is also killed.
961 String [] cmdKillIt = {
962 "pkill", "-P", Integer.toString(pid)
963 };
964 try {
965 Runtime.getRuntime().exec(cmdKillIt);
966 } catch (Throwable e) {
967 // SQUASH, this didn't work. Just bail out quietly.
968 return;
969 }
970 }
971 }
972
973 /**
974 * Hook for subclasses to be notified of the shell termination.
975 */
976 public void onShellExit() {
977 TApplication app = getApplication();
978 if (app != null) {
979 if (closeAction != null) {
980 // We have to put this action inside invokeLater() because it
981 // could be executed during draw() when syncing with ECMA48.
982 app.invokeLater(new Runnable() {
983 public void run() {
984 closeAction.DO(TTerminalWidget.this);
985 }
986 });
987 }
988 app.doRepaint();
989 }
990 }
991
992 /**
993 * Copy out variables from the emulator that TTerminal has to expose on
994 * screen.
995 */
996 private void readEmulatorState() {
997 if (emulator == null) {
998 return;
999 }
1000
1001 // Synchronize against the emulator so we don't stomp on its reader
1002 // thread.
1003 synchronized (emulator) {
1004
1005 setCursorX(emulator.getCursorX());
1006 setCursorY(emulator.getCursorY()
1007 + (getHeight() - emulator.getHeight())
1008 - getVerticalValue());
1009 setCursorVisible(emulator.isCursorVisible());
1010 if (getCursorX() > getWidth()) {
1011 setCursorVisible(false);
1012 }
1013 if ((getCursorY() >= getHeight()) || (getCursorY() < 0)) {
1014 setCursorVisible(false);
1015 }
1016 if (emulator.getScreenTitle().length() > 0) {
1017 // Only update the title if the shell is still alive
1018 if (shell != null) {
1019 title = emulator.getScreenTitle();
1020 }
1021 }
1022
1023 // Check to see if the shell has died.
1024 if (!emulator.isReading() && (shell != null)) {
1025 try {
1026 int rc = shell.exitValue();
1027 // The emulator exited on its own, all is fine
1028 title = MessageFormat.format(i18n.
1029 getString("windowTitleCompleted"), title, rc);
1030 exitValue = rc;
1031 shell = null;
1032 emulator.close();
1033 onShellExit();
1034 } catch (IllegalThreadStateException e) {
1035 // The emulator thread has exited, but the shell Process
1036 // hasn't figured that out yet. Do nothing, we will see
1037 // this in a future tick.
1038 }
1039 } else if (emulator.isReading() && (shell != null)) {
1040 // The shell might be dead, let's check
1041 try {
1042 int rc = shell.exitValue();
1043 // If we got here, the shell died.
1044 title = MessageFormat.format(i18n.
1045 getString("windowTitleCompleted"), title, rc);
1046 exitValue = rc;
1047 shell = null;
1048 emulator.close();
1049 onShellExit();
1050 } catch (IllegalThreadStateException e) {
1051 // The shell is still running, do nothing.
1052 }
1053 }
1054
1055 } // synchronized (emulator)
1056 }
1057
1058 /**
1059 * Wait for a period of time to get output from the launched process.
1060 *
1061 * @param millis millis to wait for, or 0 to wait forever
1062 * @return true if the launched process has emitted something
1063 */
1064 public boolean waitForOutput(final int millis) {
1065 if (emulator == null) {
1066 return false;
1067 }
1068 return emulator.waitForOutput(millis);
1069 }
1070
1071 /**
1072 * Check if a mouse press/release/motion event coordinate is over the
1073 * emulator.
1074 *
1075 * @param mouse a mouse-based event
1076 * @return whether or not the mouse is on the emulator
1077 */
1078 private boolean mouseOnEmulator(final TMouseEvent mouse) {
1079 if (emulator == null) {
1080 return false;
1081 }
1082
1083 if (!emulator.isReading()) {
1084 return false;
1085 }
1086
1087 if ((mouse.getX() >= 0)
1088 && (mouse.getX() < getWidth() - 1)
1089 && (mouse.getY() >= 0)
1090 && (mouse.getY() < getHeight())
1091 ) {
1092 return true;
1093 }
1094 return false;
1095 }
1096
1097 /**
1098 * Draw glyphs for a double-width or double-height VT100 cell to two
1099 * screen cells.
1100 *
1101 * @param line the line this VT100 cell is in
1102 * @param x the X position to draw the left half to
1103 * @param y the Y position to draw to
1104 * @param cell the cell to draw
1105 */
1106 private void putDoubleWidthCharXY(final DisplayLine line, final int x,
1107 final int y, final Cell cell) {
1108
1109 int textWidth = getScreen().getTextWidth();
1110 int textHeight = getScreen().getTextHeight();
1111 boolean cursorBlinkVisible = true;
1112
1113 if (getScreen() instanceof SwingTerminal) {
1114 SwingTerminal terminal = (SwingTerminal) getScreen();
1115 cursorBlinkVisible = terminal.getCursorBlinkVisible();
1116 } else if (getScreen() instanceof ECMA48Terminal) {
1117 ECMA48Terminal terminal = (ECMA48Terminal) getScreen();
1118
1119 if (!terminal.hasSixel()) {
1120 // The backend does not have sixel support, draw this as text
1121 // and bail out.
1122 putCharXY(x, y, cell);
1123 putCharXY(x + 1, y, ' ', cell);
1124 return;
1125 }
1126 cursorBlinkVisible = blinkState;
1127 } else {
1128 // We don't know how to dray glyphs to this screen, draw them as
1129 // text and bail out.
1130 putCharXY(x, y, cell);
1131 putCharXY(x + 1, y, ' ', cell);
1132 return;
1133 }
1134
1135 if ((textWidth != lastTextWidth) || (textHeight != lastTextHeight)) {
1136 // Screen size has changed, reset the font.
1137 setupFont(textHeight);
1138 lastTextWidth = textWidth;
1139 lastTextHeight = textHeight;
1140 }
1141 assert (doubleFont != null);
1142
1143 BufferedImage image;
1144 if (line.getDoubleHeight() == 1) {
1145 // Double-height top half: don't draw the underline.
1146 Cell newCell = new Cell(cell);
1147 newCell.setUnderline(false);
1148 image = doubleFont.getImage(newCell, textWidth * 2, textHeight * 2,
1149 cursorBlinkVisible);
1150 } else {
1151 image = doubleFont.getImage(cell, textWidth * 2, textHeight * 2,
1152 cursorBlinkVisible);
1153 }
1154
1155 // Now that we have the double-wide glyph drawn, copy the right
1156 // pieces of it to the cells.
1157 Cell left = new Cell(cell);
1158 Cell right = new Cell(cell);
1159 right.setChar(' ');
1160 BufferedImage leftImage = null;
1161 BufferedImage rightImage = null;
1162 /*
1163 System.err.println("image " + image + " textWidth " + textWidth +
1164 " textHeight " + textHeight);
1165 */
1166
1167 switch (line.getDoubleHeight()) {
1168 case 1:
1169 // Top half double height
1170 leftImage = image.getSubimage(0, 0, textWidth, textHeight);
1171 rightImage = image.getSubimage(textWidth, 0, textWidth, textHeight);
1172 break;
1173 case 2:
1174 // Bottom half double height
1175 leftImage = image.getSubimage(0, textHeight, textWidth, textHeight);
1176 rightImage = image.getSubimage(textWidth, textHeight,
1177 textWidth, textHeight);
1178 break;
1179 default:
1180 // Either single height double-width, or error fallback
1181 BufferedImage wideImage = new BufferedImage(textWidth * 2,
1182 textHeight, BufferedImage.TYPE_INT_ARGB);
1183 Graphics2D grWide = wideImage.createGraphics();
1184 grWide.drawImage(image, 0, 0, wideImage.getWidth(),
1185 wideImage.getHeight(), null);
1186 grWide.dispose();
1187 leftImage = wideImage.getSubimage(0, 0, textWidth, textHeight);
1188 rightImage = wideImage.getSubimage(textWidth, 0, textWidth,
1189 textHeight);
1190 break;
1191 }
1192 left.setImage(leftImage);
1193 right.setImage(rightImage);
1194 // Since we have image data, ditch the character here. Otherwise, a
1195 // drawBoxShadow() over the terminal window will show the characters
1196 // which looks wrong.
1197 left.setChar(' ');
1198 right.setChar(' ');
1199 putCharXY(x, y, left);
1200 putCharXY(x + 1, y, right);
1201 }
1202
1203 /**
1204 * Set up the double-width font.
1205 *
1206 * @param fontSize the size of font to request for the single-width font.
1207 * The double-width font will be 2x this value.
1208 */
1209 private void setupFont(final int fontSize) {
1210 doubleFont = GlyphMaker.getInstance(fontSize * 2);
1211
1212 // Special case: the ECMA48 backend needs to have a timer to drive
1213 // its blink state.
1214 if (getScreen() instanceof jexer.backend.ECMA48Terminal) {
1215 if (!haveTimer) {
1216 // Blink every 500 millis.
1217 long millis = 500;
1218 getApplication().addTimer(millis, true,
1219 new TAction() {
1220 public void DO() {
1221 blinkState = !blinkState;
1222 getApplication().doRepaint();
1223 }
1224 }
1225 );
1226 haveTimer = true;
1227 }
1228 }
1229 }
1230
1231 // ------------------------------------------------------------------------
1232 // DisplayListener --------------------------------------------------------
1233 // ------------------------------------------------------------------------
1234
1235 /**
1236 * Called by emulator when fresh data has come in.
1237 */
1238 public void displayChanged() {
1239 if (emulator != null) {
1240 // Force sync here: EMCA48.run() thread might be setting
1241 // dirty=true while TTerminalWdiget.draw() is setting
1242 // dirty=false. If these writes start interleaving, the display
1243 // stops getting updated.
1244 synchronized (emulator) {
1245 dirty = true;
1246 }
1247 } else {
1248 dirty = true;
1249 }
1250 getApplication().postEvent(new TMenuEvent(TMenu.MID_REPAINT));
1251 }
1252
1253 /**
1254 * Function to call to obtain the display width.
1255 *
1256 * @return the number of columns in the display
1257 */
1258 public int getDisplayWidth() {
1259 if (ptypipe) {
1260 return getWidth();
1261 }
1262 return 80;
1263 }
1264
1265 /**
1266 * Function to call to obtain the display height.
1267 *
1268 * @return the number of rows in the display
1269 */
1270 public int getDisplayHeight() {
1271 if (ptypipe) {
1272 return getHeight();
1273 }
1274 return 24;
1275 }
1276
1277 /**
1278 * Get the exit value for the emulator.
1279 *
1280 * @return exit value
1281 */
1282 public int getExitValue() {
1283 return exitValue;
1284 }
1285
1286 // ------------------------------------------------------------------------
1287 // EditMenuUser -----------------------------------------------------------
1288 // ------------------------------------------------------------------------
1289
1290 /**
1291 * Check if the cut menu item should be enabled.
1292 *
1293 * @return true if the cut menu item should be enabled
1294 */
1295 public boolean isEditMenuCut() {
1296 return false;
1297 }
1298
1299 /**
1300 * Check if the copy menu item should be enabled.
1301 *
1302 * @return true if the copy menu item should be enabled
1303 */
1304 public boolean isEditMenuCopy() {
1305 return false;
1306 }
1307
1308 /**
1309 * Check if the paste menu item should be enabled.
1310 *
1311 * @return true if the paste menu item should be enabled
1312 */
1313 public boolean isEditMenuPaste() {
1314 return true;
1315 }
1316
1317 /**
1318 * Check if the clear menu item should be enabled.
1319 *
1320 * @return true if the clear menu item should be enabled
1321 */
1322 public boolean isEditMenuClear() {
1323 return false;
1324 }
1325
1326 }