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());
66 private ECMA48 emulator
;
69 * The Process created by the shell spawning constructor.
71 private Process shell
;
74 * If true, we are using the ptypipe utility to support dynamic window
75 * resizing. ptypipe is available at
76 * https://github.com/klamonte/ptypipe .
78 private boolean ptypipe
= false;
81 * Claim the keystrokes the emulator will need.
83 private void addShortcutKeys() {
84 addShortcutKeypress(kbCtrlA
);
85 addShortcutKeypress(kbCtrlB
);
86 addShortcutKeypress(kbCtrlC
);
87 addShortcutKeypress(kbCtrlD
);
88 addShortcutKeypress(kbCtrlE
);
89 addShortcutKeypress(kbCtrlF
);
90 addShortcutKeypress(kbCtrlG
);
91 addShortcutKeypress(kbCtrlH
);
92 addShortcutKeypress(kbCtrlU
);
93 addShortcutKeypress(kbCtrlJ
);
94 addShortcutKeypress(kbCtrlK
);
95 addShortcutKeypress(kbCtrlL
);
96 addShortcutKeypress(kbCtrlM
);
97 addShortcutKeypress(kbCtrlN
);
98 addShortcutKeypress(kbCtrlO
);
99 addShortcutKeypress(kbCtrlP
);
100 addShortcutKeypress(kbCtrlQ
);
101 addShortcutKeypress(kbCtrlR
);
102 addShortcutKeypress(kbCtrlS
);
103 addShortcutKeypress(kbCtrlT
);
104 addShortcutKeypress(kbCtrlU
);
105 addShortcutKeypress(kbCtrlV
);
106 addShortcutKeypress(kbCtrlW
);
107 addShortcutKeypress(kbCtrlX
);
108 addShortcutKeypress(kbCtrlY
);
109 addShortcutKeypress(kbCtrlZ
);
110 addShortcutKeypress(kbF1
);
111 addShortcutKeypress(kbF2
);
112 addShortcutKeypress(kbF3
);
113 addShortcutKeypress(kbF4
);
114 addShortcutKeypress(kbF5
);
115 addShortcutKeypress(kbF6
);
116 addShortcutKeypress(kbF7
);
117 addShortcutKeypress(kbF8
);
118 addShortcutKeypress(kbF9
);
119 addShortcutKeypress(kbF10
);
120 addShortcutKeypress(kbF11
);
121 addShortcutKeypress(kbF12
);
122 addShortcutKeypress(kbAltA
);
123 addShortcutKeypress(kbAltB
);
124 addShortcutKeypress(kbAltC
);
125 addShortcutKeypress(kbAltD
);
126 addShortcutKeypress(kbAltE
);
127 addShortcutKeypress(kbAltF
);
128 addShortcutKeypress(kbAltG
);
129 addShortcutKeypress(kbAltH
);
130 addShortcutKeypress(kbAltU
);
131 addShortcutKeypress(kbAltJ
);
132 addShortcutKeypress(kbAltK
);
133 addShortcutKeypress(kbAltL
);
134 addShortcutKeypress(kbAltM
);
135 addShortcutKeypress(kbAltN
);
136 addShortcutKeypress(kbAltO
);
137 addShortcutKeypress(kbAltP
);
138 addShortcutKeypress(kbAltQ
);
139 addShortcutKeypress(kbAltR
);
140 addShortcutKeypress(kbAltS
);
141 addShortcutKeypress(kbAltT
);
142 addShortcutKeypress(kbAltU
);
143 addShortcutKeypress(kbAltV
);
144 addShortcutKeypress(kbAltW
);
145 addShortcutKeypress(kbAltX
);
146 addShortcutKeypress(kbAltY
);
147 addShortcutKeypress(kbAltZ
);
151 * Convert a string array to a whitespace-separated string.
153 * @param array the string array
154 * @return a single string
156 private String
stringArrayToString(final String
[] array
) {
157 StringBuilder sb
= new StringBuilder(array
[0].length());
158 for (int i
= 0; i
< array
.length
; i
++) {
160 if (i
< array
.length
- 1) {
164 return sb
.toString();
170 * @param command the command line to execute
172 private void spawnShell(final String
[] command
) {
175 System.err.printf("spawnShell(): '%s'\n",
176 stringArrayToString(command));
179 vScroller
= new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
183 ECMA48
.DeviceType deviceType
= ECMA48
.DeviceType
.XTERM
;
186 ProcessBuilder pb
= new ProcessBuilder(command
);
187 Map
<String
, String
> env
= pb
.environment();
188 env
.put("TERM", ECMA48
.deviceTypeTerm(deviceType
));
189 env
.put("LANG", ECMA48
.deviceTypeLang(deviceType
, "en"));
190 env
.put("COLUMNS", "80");
191 env
.put("LINES", "24");
192 pb
.redirectErrorStream(true);
194 emulator
= new ECMA48(deviceType
, shell
.getInputStream(),
195 shell
.getOutputStream(), this);
196 } catch (IOException e
) {
197 messageBox(i18n
.getString("errorLaunchingShellTitle"),
198 MessageFormat
.format(i18n
.getString("errorLaunchingShellText"),
202 // Setup the scroll bars
203 onResize(new TResizeEvent(TResizeEvent
.Type
.WIDGET
, getWidth(),
206 // Claim the keystrokes the emulator will need.
210 newStatusBar(i18n
.getString("statusBarRunning"));
214 * Public constructor spawns a custom command line.
216 * @param application TApplication that manages this window
217 * @param x column relative to parent
218 * @param y row relative to parent
219 * @param commandLine the command line to execute
221 public TTerminalWindow(final TApplication application
, final int x
,
222 final int y
, final String commandLine
) {
224 this(application
, x
, y
, RESIZABLE
, commandLine
.split("\\s"));
228 * Public constructor spawns a custom command line.
230 * @param application TApplication that manages this window
231 * @param x column relative to parent
232 * @param y row relative to parent
233 * @param flags mask of CENTERED, MODAL, or RESIZABLE
234 * @param command the command line to execute
236 public TTerminalWindow(final TApplication application
, final int x
,
237 final int y
, final int flags
, final String
[] command
) {
239 super(application
, i18n
.getString("windowTitle"), x
, y
,
240 80 + 2, 24 + 2, flags
);
242 String
[] fullCommand
;
244 // Spawn a shell and pass its I/O to the other constructor.
245 if ((System
.getProperty("jexer.TTerminal.ptypipe") != null)
246 && (System
.getProperty("jexer.TTerminal.ptypipe").
250 fullCommand
= new String
[command
.length
+ 1];
251 fullCommand
[0] = "ptypipe";
252 System
.arraycopy(command
, 0, fullCommand
, 1, command
.length
);
253 } else if (System
.getProperty("os.name").startsWith("Windows")) {
254 fullCommand
= new String
[3];
255 fullCommand
[0] = "cmd";
256 fullCommand
[1] = "/c";
257 fullCommand
[2] = stringArrayToString(command
);
258 } else if (System
.getProperty("os.name").startsWith("Mac")) {
259 fullCommand
= new String
[6];
260 fullCommand
[0] = "script";
261 fullCommand
[1] = "-q";
262 fullCommand
[2] = "-F";
263 fullCommand
[3] = "/dev/null";
264 fullCommand
[4] = "-c";
265 fullCommand
[5] = stringArrayToString(command
);
267 // Default: behave like Linux
268 fullCommand
= new String
[5];
269 fullCommand
[0] = "script";
270 fullCommand
[1] = "-fqe";
271 fullCommand
[2] = "/dev/null";
272 fullCommand
[3] = "-c";
273 fullCommand
[4] = stringArrayToString(command
);
275 spawnShell(fullCommand
);
279 * Public constructor spawns a shell.
281 * @param application TApplication that manages this window
282 * @param x column relative to parent
283 * @param y row relative to parent
284 * @param flags mask of CENTERED, MODAL, or RESIZABLE
286 public TTerminalWindow(final TApplication application
, final int x
,
287 final int y
, final int flags
) {
289 super(application
, i18n
.getString("windowTitle"), x
, y
,
290 80 + 2, 24 + 2, flags
);
292 String cmdShellWindows
= "cmd.exe";
294 // You cannot run a login shell in a bare Process interactively, due
295 // to libc's behavior of buffering when stdin/stdout aren't a tty.
296 // Use 'script' instead to run a shell in a pty. And because BSD and
297 // GNU differ on the '-f' vs '-F' flags, we need two different
299 String cmdShellGNU
= "script -fqe /dev/null";
300 String cmdShellBSD
= "script -q -F /dev/null";
302 // ptypipe is another solution that permits dynamic window resizing.
303 String cmdShellPtypipe
= "ptypipe /bin/bash --login";
305 // Spawn a shell and pass its I/O to the other constructor.
306 if ((System
.getProperty("jexer.TTerminal.ptypipe") != null)
307 && (System
.getProperty("jexer.TTerminal.ptypipe").
311 spawnShell(cmdShellPtypipe
.split("\\s"));
312 } else if (System
.getProperty("os.name").startsWith("Windows")) {
313 spawnShell(cmdShellWindows
.split("\\s"));
314 } else if (System
.getProperty("os.name").startsWith("Mac")) {
315 spawnShell(cmdShellBSD
.split("\\s"));
316 } else if (System
.getProperty("os.name").startsWith("Linux")) {
317 spawnShell(cmdShellGNU
.split("\\s"));
319 // When all else fails, assume GNU.
320 spawnShell(cmdShellGNU
.split("\\s"));
325 * Terminate the child of the 'script' process used on POSIX. This may
328 private void terminateShellChildProcess() {
330 if (shell
.getClass().getName().equals("java.lang.UNIXProcess")) {
331 /* get the PID on unix/linux systems */
333 Field field
= shell
.getClass().getDeclaredField("pid");
334 field
.setAccessible(true);
335 pid
= field
.getInt(shell
);
336 } catch (Throwable e
) {
337 // SQUASH, this didn't work. Just bail out quietly.
342 // shell.destroy() works successfully at killing this side of
343 // 'script'. But we need to make sure the other side (child
344 // process) is also killed.
345 String
[] cmdKillIt
= {
346 "pkill", "-P", Integer
.toString(pid
)
349 Runtime
.getRuntime().exec(cmdKillIt
);
350 } catch (Throwable e
) {
351 // SQUASH, this didn't work. Just bail out quietly.
358 * Draw the display buffer.
362 // Synchronize against the emulator so we don't stomp on its reader
364 synchronized (emulator
) {
366 // Update the scroll bars
369 // Draw the box using my superclass
372 List
<DisplayLine
> scrollback
= emulator
.getScrollbackBuffer();
373 List
<DisplayLine
> display
= emulator
.getDisplayBuffer();
375 // Put together the visible rows
376 int visibleHeight
= getHeight() - 2;
377 int visibleBottom
= scrollback
.size() + display
.size()
378 + getVerticalValue();
379 assert (visibleBottom
>= 0);
381 List
<DisplayLine
> preceedingBlankLines
= new LinkedList
<DisplayLine
>();
382 int visibleTop
= visibleBottom
- visibleHeight
;
383 if (visibleTop
< 0) {
384 for (int i
= visibleTop
; i
< 0; i
++) {
385 preceedingBlankLines
.add(emulator
.getBlankDisplayLine());
389 assert (visibleTop
>= 0);
391 List
<DisplayLine
> displayLines
= new LinkedList
<DisplayLine
>();
392 displayLines
.addAll(scrollback
);
393 displayLines
.addAll(display
);
395 List
<DisplayLine
> visibleLines
= new LinkedList
<DisplayLine
>();
396 visibleLines
.addAll(preceedingBlankLines
);
397 visibleLines
.addAll(displayLines
.subList(visibleTop
,
400 visibleHeight
-= visibleLines
.size();
401 assert (visibleHeight
>= 0);
403 // Now draw the emulator screen
405 for (DisplayLine line
: visibleLines
) {
406 int widthMax
= emulator
.getWidth();
407 if (line
.isDoubleWidth()) {
410 if (widthMax
> getWidth() - 2) {
411 widthMax
= getWidth() - 2;
413 for (int i
= 0; i
< widthMax
; i
++) {
414 Cell ch
= line
.charAt(i
);
415 Cell newCell
= new Cell();
417 boolean reverse
= line
.isReverseColor() ^ ch
.isReverse();
418 newCell
.setReverse(false);
420 if (ch
.getForeColorRGB() < 0) {
421 newCell
.setBackColor(ch
.getForeColor());
422 newCell
.setBackColorRGB(-1);
424 newCell
.setBackColorRGB(ch
.getForeColorRGB());
426 if (ch
.getBackColorRGB() < 0) {
427 newCell
.setForeColor(ch
.getBackColor());
428 newCell
.setForeColorRGB(-1);
430 newCell
.setForeColorRGB(ch
.getBackColorRGB());
433 if (line
.isDoubleWidth()) {
434 getScreen().putCharXY((i
* 2) + 1, row
, newCell
);
435 getScreen().putCharXY((i
* 2) + 2, row
, ' ', newCell
);
437 getScreen().putCharXY(i
+ 1, row
, newCell
);
441 if (row
== getHeight() - 1) {
442 // Don't overwrite the box edge
446 CellAttributes background
= new CellAttributes();
447 // Fill in the blank lines on bottom
448 for (int i
= 0; i
< visibleHeight
; i
++) {
449 getScreen().hLineXY(1, i
+ row
, getWidth() - 2, ' ',
453 } // synchronized (emulator)
458 * Called by emulator when fresh data has come in.
460 public void displayChanged() {
461 getApplication().postEvent(new TMenuEvent(TMenu
.MID_REPAINT
));
465 * Function to call to obtain the display width.
467 * @return the number of columns in the display
469 public int getDisplayWidth() {
471 return getWidth() - 2;
477 * Function to call to obtain the display height.
479 * @return the number of rows in the display
481 public int getDisplayHeight() {
483 return getHeight() - 2;
489 * Handle window close.
492 public void onClose() {
495 terminateShellChildProcess();
502 * Hook for subclasses to be notified of the shell termination.
504 public void onShellExit() {
505 getApplication().postEvent(new TMenuEvent(TMenu
.MID_REPAINT
));
509 * Copy out variables from the emulator that TTerminal has to expose on
512 private void readEmulatorState() {
513 // Synchronize against the emulator so we don't stomp on its reader
515 synchronized (emulator
) {
517 setCursorX(emulator
.getCursorX() + 1);
518 setCursorY(emulator
.getCursorY() + 1
519 + (getHeight() - 2 - emulator
.getHeight())
520 - getVerticalValue());
521 setCursorVisible(emulator
.isCursorVisible());
522 if (getCursorX() > getWidth() - 2) {
523 setCursorVisible(false);
525 if ((getCursorY() > getHeight() - 2) || (getCursorY() < 0)) {
526 setCursorVisible(false);
528 if (emulator
.getScreenTitle().length() > 0) {
529 // Only update the title if the shell is still alive
531 setTitle(emulator
.getScreenTitle());
535 // Check to see if the shell has died.
536 if (!emulator
.isReading() && (shell
!= null)) {
538 int rc
= shell
.exitValue();
539 // The emulator exited on its own, all is fine
540 setTitle(MessageFormat
.format(i18n
.
541 getString("windowTitleCompleted"), getTitle(), rc
));
544 clearShortcutKeypresses();
545 statusBar
.setText(MessageFormat
.format(i18n
.
546 getString("statusBarCompleted"), rc
));
548 } catch (IllegalThreadStateException e
) {
549 // The emulator thread has exited, but the shell Process
550 // hasn't figured that out yet. Do nothing, we will see
551 // this in a future tick.
553 } else if (emulator
.isReading() && (shell
!= null)) {
554 // The shell might be dead, let's check
556 int rc
= shell
.exitValue();
557 // If we got here, the shell died.
558 setTitle(MessageFormat
.format(i18n
.
559 getString("windowTitleCompleted"), getTitle(), rc
));
562 clearShortcutKeypresses();
563 statusBar
.setText(MessageFormat
.format(i18n
.
564 getString("statusBarCompleted"), rc
));
566 } catch (IllegalThreadStateException e
) {
567 // The shell is still running, do nothing.
571 } // synchronized (emulator)
575 * Handle window/screen resize events.
577 * @param resize resize event
580 public void onResize(final TResizeEvent resize
) {
582 // Synchronize against the emulator so we don't stomp on its reader
584 synchronized (emulator
) {
586 if (resize
.getType() == TResizeEvent
.Type
.WIDGET
) {
587 // Resize the scroll bars
591 // Get out of scrollback
595 emulator
.setWidth(getWidth() - 2);
596 emulator
.setHeight(getHeight() - 2);
598 emulator
.writeRemote("\033[8;" + (getHeight() - 2) + ";" +
599 (getWidth() - 2) + "t");
604 } // synchronized (emulator)
608 * Resize scrollbars for a new width/height.
611 public void reflowData() {
613 // Synchronize against the emulator so we don't stomp on its reader
615 synchronized (emulator
) {
617 // Pull cursor information
620 // Vertical scrollbar
621 setTopValue(getHeight() - 2
622 - (emulator
.getScrollbackBuffer().size()
623 + emulator
.getDisplayBuffer().size()));
624 setVerticalBigChange(getHeight() - 2);
626 } // synchronized (emulator)
630 * Check if a mouse press/release/motion event coordinate is over the
633 * @param mouse a mouse-based event
634 * @return whether or not the mouse is on the emulator
636 private final boolean mouseOnEmulator(final TMouseEvent mouse
) {
638 synchronized (emulator
) {
639 if (!emulator
.isReading()) {
644 if ((mouse
.getAbsoluteX() >= getAbsoluteX() + 1)
645 && (mouse
.getAbsoluteX() < getAbsoluteX() + getWidth() - 1)
646 && (mouse
.getAbsoluteY() >= getAbsoluteY() + 1)
647 && (mouse
.getAbsoluteY() < getAbsoluteY() + getHeight() - 1)
657 * @param keypress keystroke event
660 public void onKeypress(final TKeypressEvent keypress
) {
662 // Scrollback up/down
663 if (keypress
.equals(kbShiftPgUp
)
664 || keypress
.equals(kbCtrlPgUp
)
665 || keypress
.equals(kbAltPgUp
)
667 bigVerticalDecrement();
670 if (keypress
.equals(kbShiftPgDn
)
671 || keypress
.equals(kbCtrlPgDn
)
672 || keypress
.equals(kbAltPgDn
)
674 bigVerticalIncrement();
678 // Synchronize against the emulator so we don't stomp on its reader
680 synchronized (emulator
) {
681 if (emulator
.isReading()) {
682 // Get out of scrollback
684 emulator
.keypress(keypress
.getKey());
686 // UGLY HACK TIME! cmd.exe needs CRLF, not just CR, so if
687 // this is kBEnter then also send kbCtrlJ.
688 if (System
.getProperty("os.name").startsWith("Windows")) {
689 if (keypress
.equals(kbEnter
)) {
690 emulator
.keypress(kbCtrlJ
);
699 // Process is closed, honor "normal" TUI keystrokes
700 super.onKeypress(keypress
);
704 * Handle mouse press events.
706 * @param mouse mouse button press event
709 public void onMouseDown(final TMouseEvent mouse
) {
710 if (inWindowMove
|| inWindowResize
) {
711 // TWindow needs to deal with this.
712 super.onMouseDown(mouse
);
716 if (mouse
.isMouseWheelUp()) {
720 if (mouse
.isMouseWheelDown()) {
724 if (mouseOnEmulator(mouse
)) {
725 synchronized (emulator
) {
726 mouse
.setX(mouse
.getX() - 1);
727 mouse
.setY(mouse
.getY() - 1);
728 emulator
.mouse(mouse
);
734 // Emulator didn't consume it, pass it on
735 super.onMouseDown(mouse
);
739 * Handle mouse release events.
741 * @param mouse mouse button release event
744 public void onMouseUp(final TMouseEvent mouse
) {
745 if (inWindowMove
|| inWindowResize
) {
746 // TWindow needs to deal with this.
747 super.onMouseUp(mouse
);
751 if (mouseOnEmulator(mouse
)) {
752 synchronized (emulator
) {
753 mouse
.setX(mouse
.getX() - 1);
754 mouse
.setY(mouse
.getY() - 1);
755 emulator
.mouse(mouse
);
761 // Emulator didn't consume it, pass it on
762 super.onMouseUp(mouse
);
766 * Handle mouse motion events.
768 * @param mouse mouse motion event
771 public void onMouseMotion(final TMouseEvent mouse
) {
772 if (inWindowMove
|| inWindowResize
) {
773 // TWindow needs to deal with this.
774 super.onMouseMotion(mouse
);
778 if (mouseOnEmulator(mouse
)) {
779 synchronized (emulator
) {
780 mouse
.setX(mouse
.getX() - 1);
781 mouse
.setY(mouse
.getY() - 1);
782 emulator
.mouse(mouse
);
788 // Emulator didn't consume it, pass it on
789 super.onMouseMotion(mouse
);