#18 move to event-driven main loop
[fanfix.git] / src / jexer / TTerminalWindow.java
CommitLineData
daa4106c 1/*
34a42e78
KL
2 * Jexer - Java Text User Interface
3 *
e16dda65 4 * The MIT License (MIT)
34a42e78 5 *
a2018e99 6 * Copyright (C) 2017 Kevin Lamonte
34a42e78 7 *
e16dda65
KL
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:
34a42e78 14 *
e16dda65
KL
15 * The above copyright notice and this permission notice shall be included in
16 * all copies or substantial portions of the Software.
34a42e78 17 *
e16dda65
KL
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.
34a42e78
KL
25 *
26 * @author Kevin Lamonte [kevin.lamonte@gmail.com]
27 * @version 1
28 */
29package jexer;
30
34a42e78 31import java.io.IOException;
55d2b2c2 32import java.lang.reflect.Field;
34a42e78
KL
33import java.util.LinkedList;
34import java.util.List;
bd8d51fa 35import java.util.Map;
34a42e78
KL
36
37import jexer.bits.Cell;
38import jexer.bits.CellAttributes;
39import jexer.event.TKeypressEvent;
40import jexer.event.TMouseEvent;
41import jexer.event.TResizeEvent;
42import jexer.tterminal.DisplayLine;
be72cb5c 43import jexer.tterminal.DisplayListener;
34a42e78
KL
44import jexer.tterminal.ECMA48;
45import static jexer.TKeypress.*;
46
47/**
48 * TTerminalWindow exposes a ECMA-48 / ANSI X3.64 style terminal in a window.
49 */
be72cb5c
KL
50public class TTerminalWindow extends TScrollableWindow
51 implements DisplayListener {
34a42e78
KL
52
53 /**
54 * The emulator.
55 */
56 private ECMA48 emulator;
57
58 /**
59 * The Process created by the shell spawning constructor.
60 */
61 private Process shell;
62
5dfd1c11
KL
63 /**
64 * Claim the keystrokes the emulator will need.
65 */
66 private void addShortcutKeys() {
67 addShortcutKeypress(kbCtrlA);
68 addShortcutKeypress(kbCtrlB);
69 addShortcutKeypress(kbCtrlC);
70 addShortcutKeypress(kbCtrlD);
71 addShortcutKeypress(kbCtrlE);
72 addShortcutKeypress(kbCtrlF);
73 addShortcutKeypress(kbCtrlG);
74 addShortcutKeypress(kbCtrlH);
75 addShortcutKeypress(kbCtrlU);
76 addShortcutKeypress(kbCtrlJ);
77 addShortcutKeypress(kbCtrlK);
78 addShortcutKeypress(kbCtrlL);
79 addShortcutKeypress(kbCtrlM);
80 addShortcutKeypress(kbCtrlN);
81 addShortcutKeypress(kbCtrlO);
82 addShortcutKeypress(kbCtrlP);
83 addShortcutKeypress(kbCtrlQ);
84 addShortcutKeypress(kbCtrlR);
85 addShortcutKeypress(kbCtrlS);
86 addShortcutKeypress(kbCtrlT);
87 addShortcutKeypress(kbCtrlU);
88 addShortcutKeypress(kbCtrlV);
89 addShortcutKeypress(kbCtrlW);
90 addShortcutKeypress(kbCtrlX);
91 addShortcutKeypress(kbCtrlY);
92 addShortcutKeypress(kbCtrlZ);
93 addShortcutKeypress(kbF1);
94 addShortcutKeypress(kbF2);
95 addShortcutKeypress(kbF3);
96 addShortcutKeypress(kbF4);
97 addShortcutKeypress(kbF5);
98 addShortcutKeypress(kbF6);
99 addShortcutKeypress(kbF7);
100 addShortcutKeypress(kbF8);
101 addShortcutKeypress(kbF9);
102 addShortcutKeypress(kbF10);
103 addShortcutKeypress(kbF11);
104 addShortcutKeypress(kbF12);
105 addShortcutKeypress(kbAltA);
106 addShortcutKeypress(kbAltB);
107 addShortcutKeypress(kbAltC);
108 addShortcutKeypress(kbAltD);
109 addShortcutKeypress(kbAltE);
110 addShortcutKeypress(kbAltF);
111 addShortcutKeypress(kbAltG);
112 addShortcutKeypress(kbAltH);
113 addShortcutKeypress(kbAltU);
114 addShortcutKeypress(kbAltJ);
115 addShortcutKeypress(kbAltK);
116 addShortcutKeypress(kbAltL);
117 addShortcutKeypress(kbAltM);
118 addShortcutKeypress(kbAltN);
119 addShortcutKeypress(kbAltO);
120 addShortcutKeypress(kbAltP);
121 addShortcutKeypress(kbAltQ);
122 addShortcutKeypress(kbAltR);
123 addShortcutKeypress(kbAltS);
124 addShortcutKeypress(kbAltT);
125 addShortcutKeypress(kbAltU);
126 addShortcutKeypress(kbAltV);
127 addShortcutKeypress(kbAltW);
128 addShortcutKeypress(kbAltX);
129 addShortcutKeypress(kbAltY);
130 addShortcutKeypress(kbAltZ);
131 }
132
34a42e78
KL
133 /**
134 * Public constructor spawns a shell.
135 *
136 * @param application TApplication that manages this window
137 * @param x column relative to parent
138 * @param y row relative to parent
139 * @param flags mask of CENTERED, MODAL, or RESIZABLE
140 */
141 public TTerminalWindow(final TApplication application, final int x,
142 final int y, final int flags) {
143
144 super(application, "Terminal", x, y, 80 + 2, 24 + 2, flags);
145
56661844
KL
146 vScroller = new TVScroller(this, getWidth() - 2, 0, getHeight() - 2);
147 setBottomValue(0);
148
bd8d51fa
KL
149 // Assume XTERM
150 ECMA48.DeviceType deviceType = ECMA48.DeviceType.XTERM;
151
34a42e78
KL
152 try {
153 String [] cmdShellWindows = {
154 "cmd.exe"
155 };
156
157 // You cannot run a login shell in a bare Process interactively,
158 // due to libc's behavior of buffering when stdin/stdout aren't a
55d2b2c2
KL
159 // tty. Use 'script' instead to run a shell in a pty. And
160 // because BSD and GNU differ on the '-f' vs '-F' flags, we need
161 // two different commands. Lovely.
162 String [] cmdShellGNU = {
34a42e78
KL
163 "script", "-fqe", "/dev/null"
164 };
55d2b2c2 165 String [] cmdShellBSD = {
2ce6dab2 166 "script", "-q", "-F", "/dev/null"
55d2b2c2 167 };
34a42e78 168 // Spawn a shell and pass its I/O to the other constructor.
bd8d51fa 169
34a42e78
KL
170 ProcessBuilder pb;
171 if (System.getProperty("os.name").startsWith("Windows")) {
172 pb = new ProcessBuilder(cmdShellWindows);
55d2b2c2
KL
173 } else if (System.getProperty("os.name").startsWith("Mac")) {
174 pb = new ProcessBuilder(cmdShellBSD);
175 } else if (System.getProperty("os.name").startsWith("Linux")) {
176 pb = new ProcessBuilder(cmdShellGNU);
34a42e78 177 } else {
55d2b2c2
KL
178 // When all else fails, assume GNU.
179 pb = new ProcessBuilder(cmdShellGNU);
34a42e78 180 }
bd8d51fa
KL
181 Map<String, String> env = pb.environment();
182 env.put("TERM", ECMA48.deviceTypeTerm(deviceType));
183 env.put("LANG", ECMA48.deviceTypeLang(deviceType, "en"));
184 env.put("COLUMNS", "80");
185 env.put("LINES", "24");
34a42e78
KL
186 pb.redirectErrorStream(true);
187 shell = pb.start();
bd8d51fa 188 emulator = new ECMA48(deviceType, shell.getInputStream(),
34a42e78 189 shell.getOutputStream());
be72cb5c 190 emulator.setListener(this);
34a42e78 191 } catch (IOException e) {
fe0770f9 192 messageBox("Error", "Error launching shell: " + e.getMessage());
34a42e78
KL
193 }
194
195 // Setup the scroll bars
196 onResize(new TResizeEvent(TResizeEvent.Type.WIDGET, getWidth(),
197 getHeight()));
5dfd1c11
KL
198
199 // Claim the keystrokes the emulator will need.
200 addShortcutKeys();
2ce6dab2
KL
201
202 // Add shortcut text
203 newStatusBar("Terminal session executing...");
34a42e78
KL
204 }
205
55d2b2c2
KL
206 /**
207 * Terminate the child of the 'script' process used on POSIX. This may
208 * or may not work.
209 */
210 private void terminateShellChildProcess() {
211 int pid = -1;
212 if (shell.getClass().getName().equals("java.lang.UNIXProcess")) {
213 /* get the PID on unix/linux systems */
214 try {
215 Field field = shell.getClass().getDeclaredField("pid");
216 field.setAccessible(true);
217 pid = field.getInt(shell);
218 } catch (Throwable e) {
219 // SQUASH, this didn't work. Just bail out quietly.
220 return;
221 }
222 }
223 if (pid != -1) {
224 // shell.destroy() works successfully at killing this side of
225 // 'script'. But we need to make sure the other side (child
226 // process) is also killed.
227 String [] cmdKillIt = {
228 "pkill", "-P", Integer.toString(pid)
229 };
230 try {
231 Runtime.getRuntime().exec(cmdKillIt);
232 } catch (Throwable e) {
233 // SQUASH, this didn't work. Just bail out quietly.
234 return;
235 }
236 }
237 }
238
34a42e78
KL
239 /**
240 * Draw the display buffer.
241 */
242 @Override
243 public void draw() {
244 // Synchronize against the emulator so we don't stomp on its reader
245 // thread.
246 synchronized (emulator) {
247
248 // Update the scroll bars
56661844 249 reflowData();
34a42e78
KL
250
251 // Draw the box using my superclass
252 super.draw();
253
254 List<DisplayLine> scrollback = emulator.getScrollbackBuffer();
255 List<DisplayLine> display = emulator.getDisplayBuffer();
256
257 // Put together the visible rows
34a42e78 258 int visibleHeight = getHeight() - 2;
34a42e78 259 int visibleBottom = scrollback.size() + display.size()
56661844 260 + getVerticalValue();
34a42e78
KL
261 assert (visibleBottom >= 0);
262
263 List<DisplayLine> preceedingBlankLines = new LinkedList<DisplayLine>();
264 int visibleTop = visibleBottom - visibleHeight;
34a42e78
KL
265 if (visibleTop < 0) {
266 for (int i = visibleTop; i < 0; i++) {
267 preceedingBlankLines.add(emulator.getBlankDisplayLine());
268 }
269 visibleTop = 0;
270 }
271 assert (visibleTop >= 0);
272
273 List<DisplayLine> displayLines = new LinkedList<DisplayLine>();
274 displayLines.addAll(scrollback);
275 displayLines.addAll(display);
34a42e78
KL
276
277 List<DisplayLine> visibleLines = new LinkedList<DisplayLine>();
278 visibleLines.addAll(preceedingBlankLines);
279 visibleLines.addAll(displayLines.subList(visibleTop,
280 visibleBottom));
34a42e78
KL
281
282 visibleHeight -= visibleLines.size();
34a42e78
KL
283 assert (visibleHeight >= 0);
284
285 // Now draw the emulator screen
286 int row = 1;
287 for (DisplayLine line: visibleLines) {
288 int widthMax = emulator.getWidth();
289 if (line.isDoubleWidth()) {
290 widthMax /= 2;
291 }
292 if (widthMax > getWidth() - 2) {
293 widthMax = getWidth() - 2;
294 }
295 for (int i = 0; i < widthMax; i++) {
296 Cell ch = line.charAt(i);
297 Cell newCell = new Cell();
298 newCell.setTo(ch);
7c870d89 299 boolean reverse = line.isReverseColor() ^ ch.isReverse();
34a42e78
KL
300 newCell.setReverse(false);
301 if (reverse) {
302 newCell.setBackColor(ch.getForeColor());
303 newCell.setForeColor(ch.getBackColor());
304 }
305 if (line.isDoubleWidth()) {
306 getScreen().putCharXY((i * 2) + 1, row, newCell);
307 getScreen().putCharXY((i * 2) + 2, row, ' ', newCell);
308 } else {
309 getScreen().putCharXY(i + 1, row, newCell);
310 }
311 }
312 row++;
313 if (row == getHeight() - 1) {
314 // Don't overwrite the box edge
315 break;
316 }
317 }
318 CellAttributes background = new CellAttributes();
319 // Fill in the blank lines on bottom
320 for (int i = 0; i < visibleHeight; i++) {
321 getScreen().hLineXY(1, i + row, getWidth() - 2, ' ',
322 background);
323 }
324
325 } // synchronized (emulator)
326
327 }
328
be72cb5c
KL
329 /**
330 * Called by emulator when fresh data has come in.
331 */
332 public void displayChanged() {
333 doRepaint();
334 }
335
34a42e78
KL
336 /**
337 * Handle window close.
338 */
2ce6dab2
KL
339 @Override
340 public void onClose() {
5dfd1c11 341 emulator.close();
69345248 342 if (shell != null) {
55d2b2c2 343 terminateShellChildProcess();
69345248
KL
344 shell.destroy();
345 shell = null;
69345248 346 }
34a42e78
KL
347 }
348
349 /**
350 * Copy out variables from the emulator that TTerminal has to expose on
351 * screen.
352 */
353 private void readEmulatorState() {
354 // Synchronize against the emulator so we don't stomp on its reader
355 // thread.
356 synchronized (emulator) {
357
358 setCursorX(emulator.getCursorX() + 1);
359 setCursorY(emulator.getCursorY() + 1
56661844
KL
360 + (getHeight() - 2 - emulator.getHeight())
361 - getVerticalValue());
7c870d89 362 setCursorVisible(emulator.isCursorVisible());
34a42e78 363 if (getCursorX() > getWidth() - 2) {
7c870d89 364 setCursorVisible(false);
34a42e78
KL
365 }
366 if ((getCursorY() > getHeight() - 2) || (getCursorY() < 0)) {
7c870d89 367 setCursorVisible(false);
34a42e78
KL
368 }
369 if (emulator.getScreenTitle().length() > 0) {
370 // Only update the title if the shell is still alive
371 if (shell != null) {
372 setTitle(emulator.getScreenTitle());
373 }
374 }
34a42e78
KL
375
376 // Check to see if the shell has died.
377 if (!emulator.isReading() && (shell != null)) {
55b4f29b
KL
378 try {
379 int rc = shell.exitValue();
380 // The emulator exited on its own, all is fine
381 setTitle(String.format("%s [Completed - %d]",
0d47c546 382 getTitle(), rc));
55b4f29b
KL
383 shell = null;
384 emulator.close();
5dfd1c11 385 clearShortcutKeypresses();
2ce6dab2
KL
386 statusBar.setText("Terminal session completed, exit " +
387 "code " + rc + ".");
55b4f29b
KL
388 } catch (IllegalThreadStateException e) {
389 // The emulator thread has exited, but the shell Process
390 // hasn't figured that out yet. Do nothing, we will see
391 // this in a future tick.
392 }
34a42e78
KL
393 } else if (emulator.isReading() && (shell != null)) {
394 // The shell might be dead, let's check
395 try {
396 int rc = shell.exitValue();
397 // If we got here, the shell died.
398 setTitle(String.format("%s [Completed - %d]",
399 getTitle(), rc));
400 shell = null;
401 emulator.close();
5dfd1c11 402 clearShortcutKeypresses();
2ce6dab2
KL
403 statusBar.setText("Terminal session completed, exit " +
404 "code " + rc + ".");
34a42e78
KL
405 } catch (IllegalThreadStateException e) {
406 // The shell is still running, do nothing.
407 }
408 }
92554d64 409
34a42e78
KL
410 } // synchronized (emulator)
411 }
412
413 /**
414 * Handle window/screen resize events.
415 *
416 * @param resize resize event
417 */
418 @Override
419 public void onResize(final TResizeEvent resize) {
420
421 // Synchronize against the emulator so we don't stomp on its reader
422 // thread.
423 synchronized (emulator) {
424
425 if (resize.getType() == TResizeEvent.Type.WIDGET) {
426 // Resize the scroll bars
56661844
KL
427 reflowData();
428 placeScrollbars();
34a42e78
KL
429
430 // Get out of scrollback
56661844 431 setVerticalValue(0);
34a42e78
KL
432 }
433 return;
434
435 } // synchronized (emulator)
436 }
437
438 /**
439 * Resize scrollbars for a new width/height.
440 */
56661844
KL
441 @Override
442 public void reflowData() {
34a42e78
KL
443
444 // Synchronize against the emulator so we don't stomp on its reader
445 // thread.
446 synchronized (emulator) {
447
448 // Pull cursor information
449 readEmulatorState();
450
451 // Vertical scrollbar
56661844 452 setTopValue(getHeight() - 2
34a42e78
KL
453 - (emulator.getScrollbackBuffer().size()
454 + emulator.getDisplayBuffer().size()));
56661844 455 setVerticalBigChange(getHeight() - 2);
34a42e78
KL
456
457 } // synchronized (emulator)
458 }
459
bd8d51fa
KL
460 /**
461 * Check if a mouse press/release/motion event coordinate is over the
462 * emulator.
463 *
464 * @param mouse a mouse-based event
465 * @return whether or not the mouse is on the emulator
466 */
467 private final boolean mouseOnEmulator(final TMouseEvent mouse) {
468
469 synchronized (emulator) {
470 if (!emulator.isReading()) {
471 return false;
472 }
473 }
474
475 if ((mouse.getAbsoluteX() >= getAbsoluteX() + 1)
476 && (mouse.getAbsoluteX() < getAbsoluteX() + getWidth() - 1)
477 && (mouse.getAbsoluteY() >= getAbsoluteY() + 1)
478 && (mouse.getAbsoluteY() < getAbsoluteY() + getHeight() - 1)
479 ) {
480 return true;
481 }
482 return false;
483 }
484
34a42e78
KL
485 /**
486 * Handle keystrokes.
487 *
488 * @param keypress keystroke event
489 */
490 @Override
491 public void onKeypress(final TKeypressEvent keypress) {
492
493 // Scrollback up/down
494 if (keypress.equals(kbShiftPgUp)
495 || keypress.equals(kbCtrlPgUp)
496 || keypress.equals(kbAltPgUp)
497 ) {
56661844 498 bigVerticalDecrement();
34a42e78
KL
499 return;
500 }
501 if (keypress.equals(kbShiftPgDn)
502 || keypress.equals(kbCtrlPgDn)
503 || keypress.equals(kbAltPgDn)
504 ) {
56661844 505 bigVerticalIncrement();
34a42e78
KL
506 return;
507 }
508
509 // Synchronize against the emulator so we don't stomp on its reader
510 // thread.
511 synchronized (emulator) {
512 if (emulator.isReading()) {
513 // Get out of scrollback
56661844 514 setVerticalValue(0);
34a42e78 515 emulator.keypress(keypress.getKey());
92554d64
KL
516
517 // UGLY HACK TIME! cmd.exe needs CRLF, not just CR, so if
518 // this is kBEnter then also send kbCtrlJ.
519 if (System.getProperty("os.name").startsWith("Windows")) {
520 if (keypress.equals(kbEnter)) {
521 emulator.keypress(kbCtrlJ);
522 }
523 }
524
34a42e78
KL
525 readEmulatorState();
526 return;
527 }
528 }
529
530 // Process is closed, honor "normal" TUI keystrokes
531 super.onKeypress(keypress);
532 }
533
534 /**
535 * Handle mouse press events.
536 *
537 * @param mouse mouse button press event
538 */
539 @Override
540 public void onMouseDown(final TMouseEvent mouse) {
bd8d51fa
KL
541 if (inWindowMove || inWindowResize) {
542 // TWindow needs to deal with this.
543 super.onMouseDown(mouse);
544 return;
545 }
34a42e78 546
7c870d89 547 if (mouse.isMouseWheelUp()) {
56661844 548 verticalDecrement();
34a42e78
KL
549 return;
550 }
7c870d89 551 if (mouse.isMouseWheelDown()) {
56661844 552 verticalIncrement();
34a42e78
KL
553 return;
554 }
bd8d51fa
KL
555 if (mouseOnEmulator(mouse)) {
556 synchronized (emulator) {
557 mouse.setX(mouse.getX() - 1);
558 mouse.setY(mouse.getY() - 1);
559 emulator.mouse(mouse);
560 readEmulatorState();
561 return;
562 }
563 }
34a42e78 564
bd8d51fa 565 // Emulator didn't consume it, pass it on
34a42e78
KL
566 super.onMouseDown(mouse);
567 }
568
bd8d51fa
KL
569 /**
570 * Handle mouse release events.
571 *
572 * @param mouse mouse button release event
573 */
574 @Override
575 public void onMouseUp(final TMouseEvent mouse) {
576 if (inWindowMove || inWindowResize) {
577 // TWindow needs to deal with this.
578 super.onMouseUp(mouse);
579 return;
580 }
581
582 if (mouseOnEmulator(mouse)) {
583 synchronized (emulator) {
584 mouse.setX(mouse.getX() - 1);
585 mouse.setY(mouse.getY() - 1);
586 emulator.mouse(mouse);
587 readEmulatorState();
588 return;
589 }
590 }
591
592 // Emulator didn't consume it, pass it on
593 super.onMouseUp(mouse);
594 }
595
596 /**
597 * Handle mouse motion events.
598 *
599 * @param mouse mouse motion event
600 */
601 @Override
602 public void onMouseMotion(final TMouseEvent mouse) {
603 if (inWindowMove || inWindowResize) {
604 // TWindow needs to deal with this.
605 super.onMouseMotion(mouse);
606 return;
607 }
608
609 if (mouseOnEmulator(mouse)) {
610 synchronized (emulator) {
611 mouse.setX(mouse.getX() - 1);
612 mouse.setY(mouse.getY() - 1);
613 emulator.mouse(mouse);
614 readEmulatorState();
615 return;
616 }
617 }
618
619 // Emulator didn't consume it, pass it on
620 super.onMouseMotion(mouse);
621 }
622
34a42e78 623}