2 * Jexer - Java Text User Interface
4 * The MIT License (MIT)
6 * Copyright (C) 2017 Kevin Lamonte
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:
15 * The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
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.
26 * @author Kevin Lamonte [kevin.lamonte@gmail.com]
31 import java
.io
.IOException
;
32 import java
.lang
.reflect
.Field
;
33 import java
.text
.MessageFormat
;
34 import java
.util
.LinkedList
;
35 import java
.util
.List
;
37 import java
.util
.ResourceBundle
;
39 import jexer
.bits
.Cell
;
40 import jexer
.bits
.CellAttributes
;
41 import jexer
.event
.TKeypressEvent
;
42 import jexer
.event
.TMenuEvent
;
43 import jexer
.event
.TMouseEvent
;
44 import jexer
.event
.TResizeEvent
;
45 import jexer
.menu
.TMenu
;
46 import jexer
.tterminal
.DisplayLine
;
47 import jexer
.tterminal
.DisplayListener
;
48 import jexer
.tterminal
.ECMA48
;
49 import static jexer
.TKeypress
.*;
52 * TTerminalWindow exposes a ECMA-48 / ANSI X3.64 style terminal in a window.
54 public class TTerminalWindow
extends TScrollableWindow
55 implements DisplayListener
{
61 private static final ResourceBundle i18n
= ResourceBundle
.getBundle(TTerminalWindow
.class.getName());
63 // ------------------------------------------------------------------------
64 // Variables --------------------------------------------------------------
65 // ------------------------------------------------------------------------
70 private ECMA48 emulator
;
73 * The Process created by the shell spawning constructor.
75 private Process shell
;
78 * If true, we are using the ptypipe utility to support dynamic window
79 * resizing. ptypipe is available at
80 * https://github.com/klamonte/ptypipe .
82 private boolean ptypipe
= false;
84 // ------------------------------------------------------------------------
85 // Constructors -----------------------------------------------------------
86 // ------------------------------------------------------------------------
89 * Public constructor spawns a custom command line.
91 * @param application TApplication that manages this window
92 * @param x column relative to parent
93 * @param y row relative to parent
94 * @param commandLine the command line to execute
96 public TTerminalWindow(final TApplication application
, final int x
,
97 final int y
, final String commandLine
) {
99 this(application
, x
, y
, RESIZABLE
, commandLine
.split("\\s"));
103 * Public constructor spawns a custom command line.
105 * @param application TApplication that manages this window
106 * @param x column relative to parent
107 * @param y row relative to parent
108 * @param flags mask of CENTERED, MODAL, or RESIZABLE
109 * @param command the command line to execute
111 public TTerminalWindow(final TApplication application
, final int x
,
112 final int y
, final int flags
, final String
[] command
) {
114 super(application
, i18n
.getString("windowTitle"), x
, y
,
115 80 + 2, 24 + 2, flags
);
117 String
[] fullCommand
;
119 // Spawn a shell and pass its I/O to the other constructor.
120 if ((System
.getProperty("jexer.TTerminal.ptypipe") != null)
121 && (System
.getProperty("jexer.TTerminal.ptypipe").
125 fullCommand
= new String
[command
.length
+ 1];
126 fullCommand
[0] = "ptypipe";
127 System
.arraycopy(command
, 0, fullCommand
, 1, command
.length
);
128 } else if (System
.getProperty("os.name").startsWith("Windows")) {
129 fullCommand
= new String
[3];
130 fullCommand
[0] = "cmd";
131 fullCommand
[1] = "/c";
132 fullCommand
[2] = stringArrayToString(command
);
133 } else if (System
.getProperty("os.name").startsWith("Mac")) {
134 fullCommand
= new String
[6];
135 fullCommand
[0] = "script";
136 fullCommand
[1] = "-q";
137 fullCommand
[2] = "-F";
138 fullCommand
[3] = "/dev/null";
139 fullCommand
[4] = "-c";
140 fullCommand
[5] = stringArrayToString(command
);
142 // Default: behave like Linux
143 fullCommand
= new String
[5];
144 fullCommand
[0] = "script";
145 fullCommand
[1] = "-fqe";
146 fullCommand
[2] = "/dev/null";
147 fullCommand
[3] = "-c";
148 fullCommand
[4] = stringArrayToString(command
);
150 spawnShell(fullCommand
);
154 * Public constructor spawns a shell.
156 * @param application TApplication that manages this window
157 * @param x column relative to parent
158 * @param y row relative to parent
159 * @param flags mask of CENTERED, MODAL, or RESIZABLE
161 public TTerminalWindow(final TApplication application
, final int x
,
162 final int y
, final int flags
) {
164 super(application
, i18n
.getString("windowTitle"), x
, y
,
165 80 + 2, 24 + 2, flags
);
167 String cmdShellWindows
= "cmd.exe";
169 // You cannot run a login shell in a bare Process interactively, due
170 // to libc's behavior of buffering when stdin/stdout aren't a tty.
171 // Use 'script' instead to run a shell in a pty. And because BSD and
172 // GNU differ on the '-f' vs '-F' flags, we need two different
174 String cmdShellGNU
= "script -fqe /dev/null";
175 String cmdShellBSD
= "script -q -F /dev/null";
177 // ptypipe is another solution that permits dynamic window resizing.
178 String cmdShellPtypipe
= "ptypipe /bin/bash --login";
180 // Spawn a shell and pass its I/O to the other constructor.
181 if ((System
.getProperty("jexer.TTerminal.ptypipe") != null)
182 && (System
.getProperty("jexer.TTerminal.ptypipe").
186 spawnShell(cmdShellPtypipe
.split("\\s"));
187 } else if (System
.getProperty("os.name").startsWith("Windows")) {
188 spawnShell(cmdShellWindows
.split("\\s"));
189 } else if (System
.getProperty("os.name").startsWith("Mac")) {
190 spawnShell(cmdShellBSD
.split("\\s"));
191 } else if (System
.getProperty("os.name").startsWith("Linux")) {
192 spawnShell(cmdShellGNU
.split("\\s"));
194 // When all else fails, assume GNU.
195 spawnShell(cmdShellGNU
.split("\\s"));
199 // ------------------------------------------------------------------------
200 // TScrollableWindow ------------------------------------------------------
201 // ------------------------------------------------------------------------
204 * Draw the display buffer.
208 // Synchronize against the emulator so we don't stomp on its reader
210 synchronized (emulator
) {
212 // Update the scroll bars
215 // Draw the box using my superclass
218 List
<DisplayLine
> scrollback
= emulator
.getScrollbackBuffer();
219 List
<DisplayLine
> display
= emulator
.getDisplayBuffer();
221 // Put together the visible rows
222 int visibleHeight
= getHeight() - 2;
223 int visibleBottom
= scrollback
.size() + display
.size()
224 + getVerticalValue();
225 assert (visibleBottom
>= 0);
227 List
<DisplayLine
> preceedingBlankLines
= new LinkedList
<DisplayLine
>();
228 int visibleTop
= visibleBottom
- visibleHeight
;
229 if (visibleTop
< 0) {
230 for (int i
= visibleTop
; i
< 0; i
++) {
231 preceedingBlankLines
.add(emulator
.getBlankDisplayLine());
235 assert (visibleTop
>= 0);
237 List
<DisplayLine
> displayLines
= new LinkedList
<DisplayLine
>();
238 displayLines
.addAll(scrollback
);
239 displayLines
.addAll(display
);
241 List
<DisplayLine
> visibleLines
= new LinkedList
<DisplayLine
>();
242 visibleLines
.addAll(preceedingBlankLines
);
243 visibleLines
.addAll(displayLines
.subList(visibleTop
,
246 visibleHeight
-= visibleLines
.size();
247 assert (visibleHeight
>= 0);
249 // Now draw the emulator screen
251 for (DisplayLine line
: visibleLines
) {
252 int widthMax
= emulator
.getWidth();
253 if (line
.isDoubleWidth()) {
256 if (widthMax
> getWidth() - 2) {
257 widthMax
= getWidth() - 2;
259 for (int i
= 0; i
< widthMax
; i
++) {
260 Cell ch
= line
.charAt(i
);
261 Cell newCell
= new Cell();
263 boolean reverse
= line
.isReverseColor() ^ ch
.isReverse();
264 newCell
.setReverse(false);
266 if (ch
.getForeColorRGB() < 0) {
267 newCell
.setBackColor(ch
.getForeColor());
268 newCell
.setBackColorRGB(-1);
270 newCell
.setBackColorRGB(ch
.getForeColorRGB());
272 if (ch
.getBackColorRGB() < 0) {
273 newCell
.setForeColor(ch
.getBackColor());
274 newCell
.setForeColorRGB(-1);
276 newCell
.setForeColorRGB(ch
.getBackColorRGB());
279 if (line
.isDoubleWidth()) {
280 getScreen().putCharXY((i
* 2) + 1, row
, newCell
);
281 getScreen().putCharXY((i
* 2) + 2, row
, ' ', newCell
);
283 getScreen().putCharXY(i
+ 1, row
, newCell
);
287 if (row
== getHeight() - 1) {
288 // Don't overwrite the box edge
292 CellAttributes background
= new CellAttributes();
293 // Fill in the blank lines on bottom
294 for (int i
= 0; i
< visibleHeight
; i
++) {
295 getScreen().hLineXY(1, i
+ row
, getWidth() - 2, ' ',
299 } // synchronized (emulator)
304 * Handle window close.
307 public void onClose() {
310 terminateShellChildProcess();
317 * Handle window/screen resize events.
319 * @param resize resize event
322 public void onResize(final TResizeEvent resize
) {
324 // Synchronize against the emulator so we don't stomp on its reader
326 synchronized (emulator
) {
328 if (resize
.getType() == TResizeEvent
.Type
.WIDGET
) {
329 // Resize the scroll bars
333 // Get out of scrollback
337 emulator
.setWidth(getWidth() - 2);
338 emulator
.setHeight(getHeight() - 2);
340 emulator
.writeRemote("\033[8;" + (getHeight() - 2) + ";" +
341 (getWidth() - 2) + "t");
346 } // synchronized (emulator)
350 * Resize scrollbars for a new width/height.
353 public void reflowData() {
355 // Synchronize against the emulator so we don't stomp on its reader
357 synchronized (emulator
) {
359 // Pull cursor information
362 // Vertical scrollbar
363 setTopValue(getHeight() - 2
364 - (emulator
.getScrollbackBuffer().size()
365 + emulator
.getDisplayBuffer().size()));
366 setVerticalBigChange(getHeight() - 2);
368 } // synchronized (emulator)
374 * @param keypress keystroke event
377 public void onKeypress(final TKeypressEvent keypress
) {
379 // Scrollback up/down
380 if (keypress
.equals(kbShiftPgUp
)
381 || keypress
.equals(kbCtrlPgUp
)
382 || keypress
.equals(kbAltPgUp
)
384 bigVerticalDecrement();
387 if (keypress
.equals(kbShiftPgDn
)
388 || keypress
.equals(kbCtrlPgDn
)
389 || keypress
.equals(kbAltPgDn
)
391 bigVerticalIncrement();
395 // Synchronize against the emulator so we don't stomp on its reader
397 synchronized (emulator
) {
398 if (emulator
.isReading()) {
399 // Get out of scrollback
401 emulator
.keypress(keypress
.getKey());
403 // UGLY HACK TIME! cmd.exe needs CRLF, not just CR, so if
404 // this is kBEnter then also send kbCtrlJ.
405 if (System
.getProperty("os.name").startsWith("Windows")) {
406 if (keypress
.equals(kbEnter
)) {
407 emulator
.keypress(kbCtrlJ
);
416 // Process is closed, honor "normal" TUI keystrokes
417 super.onKeypress(keypress
);
421 * Handle mouse press events.
423 * @param mouse mouse button press event
426 public void onMouseDown(final TMouseEvent mouse
) {
427 if (inWindowMove
|| inWindowResize
) {
428 // TWindow needs to deal with this.
429 super.onMouseDown(mouse
);
433 if (mouse
.isMouseWheelUp()) {
437 if (mouse
.isMouseWheelDown()) {
441 if (mouseOnEmulator(mouse
)) {
442 synchronized (emulator
) {
443 mouse
.setX(mouse
.getX() - 1);
444 mouse
.setY(mouse
.getY() - 1);
445 emulator
.mouse(mouse
);
451 // Emulator didn't consume it, pass it on
452 super.onMouseDown(mouse
);
456 * Handle mouse release events.
458 * @param mouse mouse button release event
461 public void onMouseUp(final TMouseEvent mouse
) {
462 if (inWindowMove
|| inWindowResize
) {
463 // TWindow needs to deal with this.
464 super.onMouseUp(mouse
);
468 if (mouseOnEmulator(mouse
)) {
469 synchronized (emulator
) {
470 mouse
.setX(mouse
.getX() - 1);
471 mouse
.setY(mouse
.getY() - 1);
472 emulator
.mouse(mouse
);
478 // Emulator didn't consume it, pass it on
479 super.onMouseUp(mouse
);
483 * Handle mouse motion events.
485 * @param mouse mouse motion event
488 public void onMouseMotion(final TMouseEvent mouse
) {
489 if (inWindowMove
|| inWindowResize
) {
490 // TWindow needs to deal with this.
491 super.onMouseMotion(mouse
);
495 if (mouseOnEmulator(mouse
)) {
496 synchronized (emulator
) {
497 mouse
.setX(mouse
.getX() - 1);
498 mouse
.setY(mouse
.getY() - 1);
499 emulator
.mouse(mouse
);
505 // Emulator didn't consume it, pass it on
506 super.onMouseMotion(mouse
);
509 // ------------------------------------------------------------------------
510 // TTerminalWindow --------------------------------------------------------
511 // ------------------------------------------------------------------------
514 * Claim the keystrokes the emulator will need.
516 private void addShortcutKeys() {
517 addShortcutKeypress(kbCtrlA
);
518 addShortcutKeypress(kbCtrlB
);
519 addShortcutKeypress(kbCtrlC
);
520 addShortcutKeypress(kbCtrlD
);
521 addShortcutKeypress(kbCtrlE
);
522 addShortcutKeypress(kbCtrlF
);
523 addShortcutKeypress(kbCtrlG
);
524 addShortcutKeypress(kbCtrlH
);
525 addShortcutKeypress(kbCtrlU
);
526 addShortcutKeypress(kbCtrlJ
);
527 addShortcutKeypress(kbCtrlK
);
528 addShortcutKeypress(kbCtrlL
);
529 addShortcutKeypress(kbCtrlM
);
530 addShortcutKeypress(kbCtrlN
);
531 addShortcutKeypress(kbCtrlO
);
532 addShortcutKeypress(kbCtrlP
);
533 addShortcutKeypress(kbCtrlQ
);
534 addShortcutKeypress(kbCtrlR
);
535 addShortcutKeypress(kbCtrlS
);
536 addShortcutKeypress(kbCtrlT
);
537 addShortcutKeypress(kbCtrlU
);
538 addShortcutKeypress(kbCtrlV
);
539 addShortcutKeypress(kbCtrlW
);
540 addShortcutKeypress(kbCtrlX
);
541 addShortcutKeypress(kbCtrlY
);
542 addShortcutKeypress(kbCtrlZ
);
543 addShortcutKeypress(kbF1
);
544 addShortcutKeypress(kbF2
);
545 addShortcutKeypress(kbF3
);
546 addShortcutKeypress(kbF4
);
547 addShortcutKeypress(kbF5
);
548 addShortcutKeypress(kbF6
);
549 addShortcutKeypress(kbF7
);
550 addShortcutKeypress(kbF8
);
551 addShortcutKeypress(kbF9
);
552 addShortcutKeypress(kbF10
);
553 addShortcutKeypress(kbF11
);
554 addShortcutKeypress(kbF12
);
555 addShortcutKeypress(kbAltA
);
556 addShortcutKeypress(kbAltB
);
557 addShortcutKeypress(kbAltC
);
558 addShortcutKeypress(kbAltD
);
559 addShortcutKeypress(kbAltE
);
560 addShortcutKeypress(kbAltF
);
561 addShortcutKeypress(kbAltG
);
562 addShortcutKeypress(kbAltH
);
563 addShortcutKeypress(kbAltU
);
564 addShortcutKeypress(kbAltJ
);
565 addShortcutKeypress(kbAltK
);
566 addShortcutKeypress(kbAltL
);
567 addShortcutKeypress(kbAltM
);
568 addShortcutKeypress(kbAltN
);
569 addShortcutKeypress(kbAltO
);
570 addShortcutKeypress(kbAltP
);
571 addShortcutKeypress(kbAltQ
);
572 addShortcutKeypress(kbAltR
);
573 addShortcutKeypress(kbAltS
);
574 addShortcutKeypress(kbAltT
);
575 addShortcutKeypress(kbAltU
);
576 addShortcutKeypress(kbAltV
);
577 addShortcutKeypress(kbAltW
);
578 addShortcutKeypress(kbAltX
);
579 addShortcutKeypress(kbAltY
);
580 addShortcutKeypress(kbAltZ
);
584 * Convert a string array to a whitespace-separated string.
586 * @param array the string array
587 * @return a single string
589 private String
stringArrayToString(final String
[] array
) {
590 StringBuilder sb
= new StringBuilder(array
[0].length());
591 for (int i
= 0; i
< array
.length
; i
++) {
593 if (i
< array
.length
- 1) {
597 return sb
.toString();
603 * @param command the command line to execute
605 private void spawnShell(final String
[] command
) {
608 System.err.printf("spawnShell(): '%s'\n",
609 stringArrayToString(command));
612 vScroller
= new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
616 ECMA48
.DeviceType deviceType
= ECMA48
.DeviceType
.XTERM
;
619 ProcessBuilder pb
= new ProcessBuilder(command
);
620 Map
<String
, String
> env
= pb
.environment();
621 env
.put("TERM", ECMA48
.deviceTypeTerm(deviceType
));
622 env
.put("LANG", ECMA48
.deviceTypeLang(deviceType
, "en"));
623 env
.put("COLUMNS", "80");
624 env
.put("LINES", "24");
625 pb
.redirectErrorStream(true);
627 emulator
= new ECMA48(deviceType
, shell
.getInputStream(),
628 shell
.getOutputStream(), this);
629 } catch (IOException e
) {
630 messageBox(i18n
.getString("errorLaunchingShellTitle"),
631 MessageFormat
.format(i18n
.getString("errorLaunchingShellText"),
635 // Setup the scroll bars
636 onResize(new TResizeEvent(TResizeEvent
.Type
.WIDGET
, getWidth(),
639 // Claim the keystrokes the emulator will need.
643 newStatusBar(i18n
.getString("statusBarRunning"));
647 * Terminate the child of the 'script' process used on POSIX. This may
650 private void terminateShellChildProcess() {
652 if (shell
.getClass().getName().equals("java.lang.UNIXProcess")) {
653 /* get the PID on unix/linux systems */
655 Field field
= shell
.getClass().getDeclaredField("pid");
656 field
.setAccessible(true);
657 pid
= field
.getInt(shell
);
658 } catch (Throwable e
) {
659 // SQUASH, this didn't work. Just bail out quietly.
664 // shell.destroy() works successfully at killing this side of
665 // 'script'. But we need to make sure the other side (child
666 // process) is also killed.
667 String
[] cmdKillIt
= {
668 "pkill", "-P", Integer
.toString(pid
)
671 Runtime
.getRuntime().exec(cmdKillIt
);
672 } catch (Throwable e
) {
673 // SQUASH, this didn't work. Just bail out quietly.
680 * Called by emulator when fresh data has come in.
682 public void displayChanged() {
683 getApplication().postEvent(new TMenuEvent(TMenu
.MID_REPAINT
));
687 * Function to call to obtain the display width.
689 * @return the number of columns in the display
691 public int getDisplayWidth() {
693 return getWidth() - 2;
699 * Function to call to obtain the display height.
701 * @return the number of rows in the display
703 public int getDisplayHeight() {
705 return getHeight() - 2;
711 * Hook for subclasses to be notified of the shell termination.
713 public void onShellExit() {
714 getApplication().postEvent(new TMenuEvent(TMenu
.MID_REPAINT
));
718 * Copy out variables from the emulator that TTerminal has to expose on
721 private void readEmulatorState() {
722 // Synchronize against the emulator so we don't stomp on its reader
724 synchronized (emulator
) {
726 setCursorX(emulator
.getCursorX() + 1);
727 setCursorY(emulator
.getCursorY() + 1
728 + (getHeight() - 2 - emulator
.getHeight())
729 - getVerticalValue());
730 setCursorVisible(emulator
.isCursorVisible());
731 if (getCursorX() > getWidth() - 2) {
732 setCursorVisible(false);
734 if ((getCursorY() > getHeight() - 2) || (getCursorY() < 0)) {
735 setCursorVisible(false);
737 if (emulator
.getScreenTitle().length() > 0) {
738 // Only update the title if the shell is still alive
740 setTitle(emulator
.getScreenTitle());
744 // Check to see if the shell has died.
745 if (!emulator
.isReading() && (shell
!= null)) {
747 int rc
= shell
.exitValue();
748 // The emulator exited on its own, all is fine
749 setTitle(MessageFormat
.format(i18n
.
750 getString("windowTitleCompleted"), getTitle(), rc
));
753 clearShortcutKeypresses();
754 statusBar
.setText(MessageFormat
.format(i18n
.
755 getString("statusBarCompleted"), rc
));
757 } catch (IllegalThreadStateException e
) {
758 // The emulator thread has exited, but the shell Process
759 // hasn't figured that out yet. Do nothing, we will see
760 // this in a future tick.
762 } else if (emulator
.isReading() && (shell
!= null)) {
763 // The shell might be dead, let's check
765 int rc
= shell
.exitValue();
766 // If we got here, the shell died.
767 setTitle(MessageFormat
.format(i18n
.
768 getString("windowTitleCompleted"), getTitle(), rc
));
771 clearShortcutKeypresses();
772 statusBar
.setText(MessageFormat
.format(i18n
.
773 getString("statusBarCompleted"), rc
));
775 } catch (IllegalThreadStateException e
) {
776 // The shell is still running, do nothing.
780 } // synchronized (emulator)
784 * Check if a mouse press/release/motion event coordinate is over the
787 * @param mouse a mouse-based event
788 * @return whether or not the mouse is on the emulator
790 private final boolean mouseOnEmulator(final TMouseEvent mouse
) {
792 synchronized (emulator
) {
793 if (!emulator
.isReading()) {
798 if ((mouse
.getAbsoluteX() >= getAbsoluteX() + 1)
799 && (mouse
.getAbsoluteX() < getAbsoluteX() + getWidth() - 1)
800 && (mouse
.getAbsoluteY() >= getAbsoluteY() + 1)
801 && (mouse
.getAbsoluteY() < getAbsoluteY() + getHeight() - 1)