From e412d8de12a1d3ce27b056339a2fb7273d7a2124 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 31 May 2016 01:07:57 +0200 Subject: [PATCH] Fixed Unsupported console in IDE to more closely emulate single character input. Cleaned up terminal class heirarchy --- src/dorkbox/console/Console.java | 247 ++------- src/dorkbox/console/Input.java | 470 ------------------ src/dorkbox/console/input/Input.java | 124 +++++ src/dorkbox/console/input/PosixTerminal.java | 24 +- .../console/input/SupportedTerminal.java | 293 +++++++++++ src/dorkbox/console/input/Terminal.java | 89 +++- .../console/input/UnsupportedTerminal.java | 135 +++-- .../console/input/WindowsTerminal.java | 14 +- src/dorkbox/console/output/Ansi.java | 129 ++++- 9 files changed, 784 insertions(+), 741 deletions(-) delete mode 100644 src/dorkbox/console/Input.java create mode 100644 src/dorkbox/console/input/Input.java create mode 100644 src/dorkbox/console/input/SupportedTerminal.java diff --git a/src/dorkbox/console/Console.java b/src/dorkbox/console/Console.java index 6c0d1c9..22f687d 100644 --- a/src/dorkbox/console/Console.java +++ b/src/dorkbox/console/Console.java @@ -15,32 +15,28 @@ */ package dorkbox.console; -import static dorkbox.console.Input.readLinePassword; - -import java.io.FilterOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.io.PrintStream; +import dorkbox.console.input.Input; +import dorkbox.console.input.Terminal; +import dorkbox.console.output.Ansi; import dorkbox.console.output.AnsiOutputStream; -import dorkbox.console.output.WindowsAnsiOutputStream; -import dorkbox.console.util.posix.CLibraryPosix; -import dorkbox.console.util.windows.Kernel32; import dorkbox.util.Property; /** - * Provides a fluent API for generating ANSI escape sequences and providing access to streams that support it. - *

- * See: https://en.wikipedia.org/wiki/ANSI_escape_code + * Provides access to single character input streams and ANSI capable output streams. * * @author dorkbox, llc */ +@SuppressWarnings("unused") public class Console { /** - * If true, allows an ANSI output stream to be created, otherwise a NO-OP stream is created instead + * If true, allows an ANSI output stream to be created on System.out/err, If true, allows an ANSI output stream to be created on + * System.out/err, otherwise it will provide an ANSI aware PrintStream which strips out the ANSI escape sequences. */ @Property public static boolean ENABLE_ANSI = true; @@ -53,13 +49,14 @@ class Console { public static boolean FORCE_ENABLE_ANSI = false; /** - * Enables or disables character echo to stdout in the console, should call {@link #setEchoEnabled(boolean)} after initialization + * Enables or disables character echo to stdout in the console, should call {@link Terminal#setEchoEnabled(boolean)} after + * initialization */ @Property public static volatile boolean ENABLE_ECHO = true; /** - * Enables or disables CTRL-C behavior in the console, should call {@link #setInterruptEnabled(boolean)} after initialization + * Enables or disables CTRL-C behavior in the console, should call {@link Terminal#setInterruptEnabled(boolean)} after initialization */ @Property public static volatile boolean ENABLE_INTERRUPT = false; @@ -73,6 +70,7 @@ class Console { /** + * Used to determine what console to use/hook when AUTO is not correctly working. * Valid options are: * AUTO - automatically determine which OS/console type to use * UNIX - try to control a UNIX console @@ -83,16 +81,6 @@ class Console { public static final String INPUT_CONSOLE_TYPE = "AUTO"; - - private static final PrintStream original_out = System.out; - private static final PrintStream original_err = System.err; - - // protected by synchronize - private static int installed = 0; - private static PrintStream out; - private static PrintStream err; - - /** * Gets the version number. */ @@ -101,108 +89,49 @@ class Console { return "2.9"; } + /** - * Reads single character input from the console. + * If the standard in supports single character input, then a terminal will be returned that supports it, otherwise a buffered (aka + * 'normal') input will be returned * - * @return -1 if no data or problems + * @return a terminal that supports single character input or the default buffered input */ public static - int read() { - return Input.read(); + Terminal in() { + return Input.terminal; } /** - * Reads a line of characters from the console, defined as everything before the 'ENTER' key is pressed + * If the standard in supports single character input, then an InputStream will be returned that supports it, otherwise a buffered (aka + * 'normal') InputStream will be returned * - * @return null if no data + * @return an InputStream that supports single character input or the default buffered input */ public static - String readLine() { - return Input.readLine(); + InputStream inputStream() { + return Input.wrappedInputStream; } - /** - * Reads a line of characters from the console as a character array, defined as everything before the 'ENTER' key is pressed + * If the standard out natively supports ANSI escape codes, then this just returns System.out (wrapped to reset ANSI stream on close), + * otherwise it will provide an ANSI aware PrintStream which strips out the ANSI escape sequences. * - * @return empty char[] if no data + * @return a PrintStream which is ANSI aware. */ public static - char[] readLineChars() { - return Input.readLineChars(); + PrintStream out() { + return Ansi.out; } /** - * Reads a line of characters from the console as a character array, defined as everything before the 'ENTER' key is pressed + * If the standard out natively supports ANSI escape codes, then this just returns System.err (wrapped to reset ANSI stream on close), + * otherwise it will provide an ANSI aware PrintStream which strips out the ANSI escape sequences. * - * @return empty char[] if no data + * @return a PrintStream which is ANSI aware. */ public static - char[] readPassword() { - return readLinePassword(); - } - - /** - * Reads an InputStream capable of reading a single character at a time - */ - public static - InputStream getInputStream() { - return Input.getInputStream(); - } - - /** - * Enables or disables CTRL-C behavior in the console - */ - public static - void setInterruptEnabled(final boolean enabled) { - Console.ENABLE_INTERRUPT = enabled; - Input.setInterruptEnabled(enabled); - } - - /** - * Enables or disables character echo to stdout - */ - public static - void setEchoEnabled(final boolean enabled) { - Console.ENABLE_ECHO = enabled; - Input.setEchoEnabled(enabled); - } - - /** - * Override System.err and System.out with an ANSI capable {@link java.io.PrintStream}. - */ - public static synchronized - void systemInstall() { - installed++; - if (installed == 1) { - out = out(); - err = err(); - - System.setOut(out); - System.setErr(err); - } - } - - /** - * un-does a previous {@link #systemInstall()}. - *

- * If {@link #systemInstall()} was called multiple times, then {@link #systemUninstall()} must be called the same number of - * times before it is uninstalled. - */ - public static synchronized - void systemUninstall() { - installed--; - if (installed == 0) { - if (out != null && out != original_out) { - out.close(); - System.setOut(original_out); - } - - if (err != null && err != original_err) { - err.close(); - System.setErr(original_err); - } - } + PrintStream err() { + return Ansi.err; } /** @@ -211,115 +140,11 @@ class Console { */ public static synchronized void reset() { - if (installed >= 1) { - try { - System.out.write(AnsiOutputStream.RESET_CODE); - } catch (IOException e) { - e.printStackTrace(); - } + try { + Ansi.out.write(AnsiOutputStream.RESET_CODE); + } catch (IOException e) { + e.printStackTrace(); } } - - - /** - * If the standard out natively supports ANSI escape codes, then this just returns System.out, otherwise it will provide an ANSI - * aware PrintStream which strips out the ANSI escape sequences or which implement the escape sequences. - * - * @return a PrintStream which is ANSI aware. - */ - public static - PrintStream out() { - if (out == null) { - out = createPrintStream(original_out, 1); // STDOUT_FILENO - } - return out; - } - - /** - * If the standard out natively supports ANSI escape codes, then this just returns System.err, otherwise it will provide an ANSI aware - * PrintStream which strips out the ANSI escape sequences or which implement the escape sequences. - * - * @return a PrintStream which is ANSI aware. - */ - public static - PrintStream err() { - if (err == null) { - err = createPrintStream(original_err, 2); // STDERR_FILENO - } - return err; - } - - - - - - - - - - private static boolean isXterm() { - String term = System.getenv("TERM"); - return "xterm".equalsIgnoreCase(term); - } - - private static - PrintStream createPrintStream(final OutputStream stream, int fileno) { - if (!ENABLE_ANSI) { - // Use the ANSIOutputStream to strip out the ANSI escape sequences. - return new PrintStream(new AnsiOutputStream(stream)); - } - - if (!isXterm()) { - String os = System.getProperty("os.name"); - if (os.startsWith("Windows")) { - // check if windows10+ (which natively supports ANSI) - if (Kernel32.isWindows10OrGreater()) { - // Just wrap it up so that when we get closed, we reset the attributes. - return deafultPrintStream(stream); - } - - // On windows we know the console does not interpret ANSI codes.. - try { - return new PrintStream(new WindowsAnsiOutputStream(stream, fileno)); - } catch (Throwable ignore) { - // this happens when JNA is not in the path.. or - // this happens when the stdout is being redirected to a file. - // this happens when the stdout is being redirected to different console. - } - - // Use the ANSIOutputStream to strip out the ANSI escape sequences. - if (!FORCE_ENABLE_ANSI) { - return new PrintStream(new AnsiOutputStream(stream)); - } - } else { - // We must be on some unix variant.. - try { - // If we can detect that stdout is not a tty.. then setup to strip the ANSI sequences.. - if (!FORCE_ENABLE_ANSI && CLibraryPosix.isatty(fileno) == 0) { - return new PrintStream(new AnsiOutputStream(stream)); - } - } catch (Throwable ignore) { - // These errors happen if the JNI lib is not available for your platform. - } - } - } - - // By default we assume the terminal can handle ANSI codes. - // Just wrap it up so that when we get closed, we reset the attributes. - return deafultPrintStream(stream); - } - - private static - PrintStream deafultPrintStream(final OutputStream stream) { - return new PrintStream(new FilterOutputStream(stream) { - @Override - public - void close() throws IOException { - write(AnsiOutputStream.RESET_CODE); - flush(); - super.close(); - } - }); - } } diff --git a/src/dorkbox/console/Input.java b/src/dorkbox/console/Input.java deleted file mode 100644 index 558f2bd..0000000 --- a/src/dorkbox/console/Input.java +++ /dev/null @@ -1,470 +0,0 @@ -/* - * Copyright 2010 dorkbox, llc - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package dorkbox.console; - -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintStream; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -import org.slf4j.Logger; - -import dorkbox.console.input.PosixTerminal; -import dorkbox.console.input.Terminal; -import dorkbox.console.input.UnsupportedTerminal; -import dorkbox.console.input.WindowsTerminal; -import dorkbox.console.output.Ansi; -import dorkbox.console.util.CharHolder; -import dorkbox.objectPool.ObjectPool; -import dorkbox.objectPool.PoolableObject; -import dorkbox.util.OS; -import dorkbox.util.bytes.ByteBuffer2; -import dorkbox.util.bytes.ByteBuffer2Poolable; - -class Input { - private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Input.class); - private static final char[] emptyLine = new char[0]; - - - private static final List charInputBuffers = new ArrayList(); - private final static ObjectPool charInputPool = ObjectPool.NonBlocking(new PoolableObject() { - @Override - public - CharHolder create() { - return new CharHolder(); - } - - @Override - public - void onReturn(final CharHolder object) { - // dump the chars in the buffer (safer for passwords, etc) - object.character = (char) 0; - charInputBuffers.remove(object); - } - - @Override - public - void onTake(final CharHolder object) { - charInputBuffers.add(object); - } - }); - - - private static final List lineInputBuffers = new ArrayList(); - private final static ObjectPool lineInputPool = ObjectPool.NonBlocking(new ByteBuffer2Poolable() { - @Override - public - void onReturn(final ByteBuffer2 object) { - // dump the chars in the buffer (safer for passwords, etc) - object.clearSecure(); - lineInputBuffers.remove(object); - } - - @Override - public - void onTake(final ByteBuffer2 object) { - lineInputBuffers.add(object); - } - }); - - - - private final static Terminal terminal; - - private static final Object inputLock = new Object(); - private static final Object inputLockSingle = new Object(); - private static final Object inputLockLine = new Object(); - - static { - String type = Console.INPUT_CONSOLE_TYPE.toUpperCase(Locale.ENGLISH); - - Throwable didFallbackE = null; - Terminal term; - try { - if (type.equals("UNIX")) { - term = new PosixTerminal(); - } - else if (type.equals("WINDOWS")) { - term = new WindowsTerminal(); - } - else if (type.equals("NONE")) { - term = new UnsupportedTerminal(); - } - else { - // if these cannot be created, because we are in an IDE, an error will be thrown - if (OS.isWindows()) { - term = new WindowsTerminal(); - } - else { - term = new PosixTerminal(); - } - } - } catch (Exception e) { - didFallbackE = e; - term = new UnsupportedTerminal(); - } - - terminal = term; - - - if (logger.isDebugEnabled()) { - logger.debug("Created Terminal: {} ({}w x {}h)", Input.terminal.getClass().getSimpleName(), - Input.terminal.getWidth(), Input.terminal.getHeight()); - } - - if (didFallbackE != null && !didFallbackE.getMessage().equals(Terminal.CONSOLE_ERROR_INIT)) { - logger.error("Failed to construct terminal, falling back to unsupported.", didFallbackE); - } else if (term instanceof UnsupportedTerminal) { - logger.debug("Terminal is in UNSUPPORTED (best guess). Unable to support single key input. Only line input available."); - } - - // echo and backspace - term.setEchoEnabled(Console.ENABLE_ECHO); - term.setInterruptEnabled(Console.ENABLE_INTERRUPT); - - Thread consoleThread = new Thread(new Runnable() { - @Override - public - void run() { - Input.run(); - } - }); - consoleThread.setDaemon(true); - consoleThread.setName("Console Input Reader"); - - consoleThread.start(); - - // has to be NOT DAEMON thread, since it must run before the app closes. - - // don't forget we have to shut down the ansi console as well - // alternatively, shut everything down when the JVM closes. - Thread shutdownThread = new Thread() { - @Override - public - void run() { - // called when the JVM is shutting down. - release0(); - - try { - terminal.restore(); - // this will 'hang' our shutdown, and honestly, who cares? We're shutting down anyways. - // inputConsole.reader.close(); // hangs on shutdown - } catch (IOException ignored) { - ignored.printStackTrace(); - } - } - }; - shutdownThread.setName("Console Input Shutdown"); - Runtime.getRuntime().addShutdownHook(shutdownThread); - } - - static - void setInterruptEnabled(final boolean enabled) { - terminal.setInterruptEnabled(enabled); - } - - static - void setEchoEnabled(final boolean enabled) { - terminal.setEchoEnabled(enabled); - } - - private static InputStream wrappedInputStream = new InputStream() { - @Override - public - int read() throws IOException { - return Input.read(); - } - - @Override - public - void close() throws IOException { - Input.release0(); - } - }; - - static - InputStream getInputStream() { - return wrappedInputStream; - } - - private - Input() { - } - - /** - * Reads single character input from the console. - * - * @return -1 if no data or problems - */ - static - int read() { - CharHolder holder; - - synchronized (inputLock) { - // don't want to register a read() WHILE we are still processing the current input. - // also adds it to the global list of char inputs - holder = charInputPool.take(); - } - - synchronized (inputLockSingle) { - try { - inputLockSingle.wait(); - } catch (InterruptedException e) { - return -1; - } - } - - char c = holder.character; - - // also clears and removes from the global list of char inputs - charInputPool.put(holder); - - return c; - } - - /** - * @return empty char[] if no data or problems - */ - static - char[] readLineChars() { - ByteBuffer2 buffer; - - synchronized (inputLock) { - // don't want to register a readLine() WHILE we are still processing the current line info. - // also adds it to the global list of line inputs - buffer = lineInputPool.take(); - } - - synchronized (inputLockLine) { - try { - inputLockLine.wait(); - } catch (InterruptedException e) { - return emptyLine; - } - } - - int len = buffer.position(); - if (len == 0) { - return emptyLine; - } - - buffer.rewind(); - char[] readChars = buffer.readChars(len / 2); // java always stores chars in 2 bytes - - // also clears and removes from the global list of line inputs - lineInputPool.put(buffer); - - return readChars; - } - - /** - * Reads line input from the console - * - * @return empty char[] if no data - */ - static - char[] readLinePassword() { - // don't bother in an IDE. it won't work. - boolean echoEnabled = Console.ENABLE_ECHO; - Console.ENABLE_ECHO = false; - char[] readLine0 = readLineChars(); - Console.ENABLE_ECHO = echoEnabled; - - return readLine0; - } - - /** - * Reads a single line of characters, defined as everything before the 'ENTER' key is pressed - * @return null if no data - */ - static - String readLine() { - char[] line = Input.readLineChars(); - if (line == null) { - return null; - } - return new String(line); - } - - - - /** - * releases any thread still waiting. - */ - private static - void release0() { - synchronized (inputLockSingle) { - inputLockSingle.notifyAll(); - } - - synchronized (inputLockLine) { - inputLockLine.notifyAll(); - } - } - - private static - void run() { - final Logger logger2 = logger; - final PrintStream out = System.out; - final char overWriteChar = ' '; - - Ansi ansi = null; - int typedChar; - char asChar; - - while ((typedChar = terminal.read()) != -1) { - synchronized (inputLock) { - // don't let anyone add a new reader while we are still processing the current actions - asChar = (char) typedChar; - - if (logger2.isTraceEnabled()) { - logger2.trace("READ: {} ({})", asChar, typedChar); - } - - // notify everyone waiting for a character. - synchronized (inputLockSingle) { - // have to do readChar first (readLine has to deal with \b and \n - for (CharHolder holder : charInputBuffers) { - holder.character = asChar; // copy by value - } - - inputLockSingle.notifyAll(); - } - - // now to handle readLine stuff - - // if we type a backspace key, swallow it + previous in READLINE. READCHAR will have it passed anyways. - if (Console.ENABLE_BACKSPACE && asChar == '\b') { - int position = 0; - char[] overwrite = null; - - // clear ourself + one extra. - for (ByteBuffer2 buffer : lineInputBuffers) { - // size of the buffer BEFORE our backspace was typed - int length = buffer.position(); - int amtToOverwrite = 4; // 2*2 backspace is always 2 chars (^?) * 2 because it's bytes - - if (length > 1) { - char charAt = buffer.readChar(length - 2); - amtToOverwrite += getPrintableCharacters(charAt); - - // delete last item in our buffer - length -= 2; - buffer.setPosition(length); - - // now figure out where the cursor is really at. - // this is more memory friendly than buf.toString.length - for (int i = 0; i < length; i += 2) { - charAt = buffer.readChar(i); - position += getPrintableCharacters(charAt); - } - - position++; - } - - overwrite = new char[amtToOverwrite]; - for (int i = 0; i < amtToOverwrite; i++) { - overwrite[i] = overWriteChar; - } - } - - if (Console.ENABLE_ANSI && overwrite != null) { - if (ansi == null) { - ansi = Ansi.ansi(); - } - - // move back however many, over write, then go back again - out.print(ansi.cursorToColumn(position)); - out.print(overwrite); - out.print(ansi.cursorToColumn(position)); - out.flush(); - } - } - else if (asChar == '\n') { - // ignoring \r, because \n is ALWAYS the last character in a new line sequence. (even for windows, which we changed) - synchronized (inputLockLine) { - inputLockLine.notifyAll(); - } - } - else { - // only append if we are not a new line. - // our windows console PREVENTS us from returning '\r' (it truncates '\r\n', and returns just '\n') - for (ByteBuffer2 buffer : lineInputBuffers) { - buffer.writeChar(asChar); - } - } - } - } - } - - private static final int PLUS_TWO_MAYBE = 128 + 32; - private static final int PLUS_ONE = 128 + 127; - - /** - * Return the number of characters that will be printed when the specified character is echoed to the screen - *

- * Adapted from cat by Torbjorn Granlund, as repeated in stty by David MacKenzie. - */ - private static - int getPrintableCharacters(final int ch) { - // StringBuilder sbuff = new StringBuilder(); - - if (ch >= 32) { - if (ch < 127) { - // sbuff.append((char) ch); - return 1; - } - else if (ch == 127) { - // sbuff.append('^'); - // sbuff.append('?'); - return 2; - } - else { - // sbuff.append('M'); - // sbuff.append('-'); - int count = 2; - - if (ch >= PLUS_TWO_MAYBE) { - if (ch < PLUS_ONE) { - // sbuff.append((char) (ch - 128)); - count++; - } - else { - // sbuff.append('^'); - // sbuff.append('?'); - count += 2; - } - } - else { - // sbuff.append('^'); - // sbuff.append((char) (ch - 128 + 64)); - count += 2; - } - return count; - } - } - else { - // sbuff.append('^'); - // sbuff.append((char) (ch + 64)); - return 2; - } - - // return sbuff; - } -} - diff --git a/src/dorkbox/console/input/Input.java b/src/dorkbox/console/input/Input.java new file mode 100644 index 0000000..f8cfcb5 --- /dev/null +++ b/src/dorkbox/console/input/Input.java @@ -0,0 +1,124 @@ +/* + * Copyright 2010 dorkbox, llc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dorkbox.console.input; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; + +import dorkbox.console.Console; +import dorkbox.util.OS; + +public +class Input { + private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Console.class); + + public final static Terminal terminal; + static { + String type = Console.INPUT_CONSOLE_TYPE.toUpperCase(Locale.ENGLISH); + + Throwable didFallbackE = null; + Terminal term; + try { + if (type.equals("UNIX")) { + term = new PosixTerminal(); + } + else if (type.equals("WINDOWS")) { + term = new WindowsTerminal(); + } + else if (type.equals("NONE")) { + term = new UnsupportedTerminal(); + } + else { + // if these cannot be created, because we are in an IDE, an error will be thrown + if (OS.isWindows()) { + term = new WindowsTerminal(); + } + else { + term = new PosixTerminal(); + } + } + } catch (Exception e) { + didFallbackE = e; + term = new UnsupportedTerminal(); + } + + terminal = term; + + boolean debugEnabled = logger.isDebugEnabled(); + if (didFallbackE != null && !didFallbackE.getMessage().equals(Terminal.CONSOLE_ERROR_INIT)) { + logger.error("Failed to construct terminal, falling back to unsupported.", didFallbackE); + } else if (debugEnabled && term instanceof UnsupportedTerminal) { + logger.debug("Terminal is UNSUPPORTED (best guess). Unable to support single key input. Only line input available."); + } else if (debugEnabled) { + logger.debug("Created Terminal: {} ({}w x {}h)", Input.terminal.getClass().getSimpleName(), + Input.terminal.getWidth(), Input.terminal.getHeight()); + } + + + if (term instanceof SupportedTerminal) { + // echo and backspace + term.setEchoEnabled(Console.ENABLE_ECHO); + term.setInterruptEnabled(Console.ENABLE_INTERRUPT); + + Thread consoleThread = new Thread((SupportedTerminal)term); + consoleThread.setDaemon(true); + consoleThread.setName("Console Input Reader"); + consoleThread.start(); + + + // has to be NOT DAEMON thread, since it must run before the app closes. + // alternatively, shut everything down when the JVM closes. + Thread shutdownThread = new Thread() { + @Override + public + void run() { + // called when the JVM is shutting down. + terminal.close(); + + try { + terminal.restore(); + // this will 'hang' our shutdown, and honestly, who cares? We're shutting down anyways. + // inputConsole.reader.close(); // hangs on shutdown + } catch (IOException ignored) { + ignored.printStackTrace(); + } + } + }; + shutdownThread.setName("Console Input Shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownThread); + } + } + + public final static InputStream wrappedInputStream = new InputStream() { + @Override + public + int read() throws IOException { + return terminal.read(); + } + + @Override + public + void close() throws IOException { + terminal.close(); + } + }; + + private + Input() { + } +} + diff --git a/src/dorkbox/console/input/PosixTerminal.java b/src/dorkbox/console/input/PosixTerminal.java index 20f734f..a09cee6 100644 --- a/src/dorkbox/console/input/PosixTerminal.java +++ b/src/dorkbox/console/input/PosixTerminal.java @@ -29,7 +29,7 @@ import dorkbox.console.util.posix.Termios; * This implementation should work for an reasonable POSIX system. */ public -class PosixTerminal extends Terminal { +class PosixTerminal extends SupportedTerminal { private final Termios original = new Termios(); private Termios termInfo = new Termios(); @@ -112,14 +112,8 @@ class PosixTerminal extends Terminal { } @Override - public final - int read() { - CLibraryPosix.read(0, inputRef, 1); - return inputRef.getValue(); - } - - public - void setEchoEnabled(final boolean enabled) { + protected + void doSetEchoEnabled(final boolean enabled) { // have to re-get them, since flags change everything if (CLibraryPosix.tcgetattr(0, this.termInfo) != 0) { this.logger.error("Failed to get terminal info"); @@ -137,8 +131,9 @@ class PosixTerminal extends Terminal { } } - public - void setInterruptEnabled(final boolean enabled) { + @Override + protected + void doSetInterruptEnabled(final boolean enabled) { // have to re-get them, since flags change everything if (CLibraryPosix.tcgetattr(0, this.termInfo) != 0) { this.logger.error("Failed to get terminal info"); @@ -155,4 +150,11 @@ class PosixTerminal extends Terminal { this.logger.error("Can not set terminal flags"); } } + + @Override + protected final + int doRead() { + CLibraryPosix.read(0, inputRef, 1); + return inputRef.getValue(); + } } diff --git a/src/dorkbox/console/input/SupportedTerminal.java b/src/dorkbox/console/input/SupportedTerminal.java new file mode 100644 index 0000000..4d1b8ca --- /dev/null +++ b/src/dorkbox/console/input/SupportedTerminal.java @@ -0,0 +1,293 @@ +/* + * Copyright 2010 dorkbox, llc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package dorkbox.console.input; + +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; + +import dorkbox.console.Console; +import dorkbox.console.output.Ansi; +import dorkbox.console.util.CharHolder; +import dorkbox.util.FastThreadLocal; +import dorkbox.util.bytes.ByteBuffer2; + +public abstract +class SupportedTerminal extends Terminal implements Runnable { + private final PrintStream out = System.out; + + private static final char[] emptyLine = new char[0]; + + protected final Object inputLockLine = new Object(); + protected final Object inputLockSingle = new Object(); + + protected final List charInputBuffers = new ArrayList(); + protected final FastThreadLocal charInput = new FastThreadLocal() { + @Override + public + CharHolder initialValue() { + return new CharHolder(); + } + }; + + private final List lineInputBuffers = new ArrayList(); + private final FastThreadLocal lineInput = new FastThreadLocal() { + @Override + public + ByteBuffer2 initialValue() { + return new ByteBuffer2(8, -1); + } + }; + + public + SupportedTerminal() { + } + + /** + * Reads single character input from the console. + * + * @return -1 if no data or problems + */ + public final + int read() { + CharHolder holder = charInput.get(); + + synchronized (inputLockSingle) { + // don't want to register a read() WHILE we are still processing the current input. + // also adds it to the global list of char inputs + charInputBuffers.add(holder); + + try { + inputLockSingle.wait(); + } catch (InterruptedException e) { + return -1; + } + + char c = holder.character; + + // also clears and removes from the global list of char inputs + charInputBuffers.remove(holder); + return c; + } + } + + /** + * Reads a line of characters from the console as a character array, defined as everything before the 'ENTER' key is pressed + * + * @return empty char[] if no data + */ + public final + char[] readLineChars() { + ByteBuffer2 buffer = lineInput.get(); + + synchronized (inputLockLine) { + // don't want to register a readLine() WHILE we are still processing the current line info. + // also adds it to the global list of line inputs + lineInputBuffers.add(buffer); + + try { + inputLockLine.wait(); + } catch (InterruptedException e) { + return emptyLine; + } + + int len = buffer.position(); + if (len == 0) { + return emptyLine; + } + + buffer.rewind(); + char[] readChars = buffer.readChars(len / 2); // java always stores chars in 2 bytes + + // dump the chars in the buffer (safer for passwords, etc) + buffer.clearSecure(); + + // also clears and removes from the global list of line inputs + lineInputBuffers.remove(buffer); + + return readChars; + } + } + + /** + * releases any thread still waiting. + */ + public final + void close() { + synchronized (inputLockSingle) { + inputLockSingle.notifyAll(); + } + + synchronized (inputLockLine) { + inputLockLine.notifyAll(); + } + } + + /** + * Reads a single character from whatever underlying stream is available. + */ + protected abstract int doRead(); + + public + void run() { + final Logger logger2 = logger; + final char overWriteChar = ' '; + + Ansi ansi = null; + int typedChar; + char asChar; + + while ((typedChar = doRead()) != -1) { + // don't let anyone add a new reader while we are still processing the current actions + asChar = (char) typedChar; + + if (logger2.isTraceEnabled()) { + logger2.trace("READ: {} ({})", asChar, typedChar); + } + + // notify everyone waiting for a character. + synchronized (inputLockSingle) { + // have to do readChar first (readLine has to deal with \b and \n + for (CharHolder holder : charInputBuffers) { + holder.character = asChar; // copy by value + } + + inputLockSingle.notifyAll(); + } + + // now to handle readLine stuff + + // if we type a backspace key, swallow it + previous in READLINE. READCHAR will have it passed anyways. + if (Console.ENABLE_BACKSPACE && asChar == '\b') { + int position = 0; + char[] overwrite = null; + + // clear ourself + one extra. + for (ByteBuffer2 buffer : lineInputBuffers) { + // size of the buffer BEFORE our backspace was typed + int length = buffer.position(); + int amtToOverwrite = 4; // 2*2 backspace is always 2 chars (^?) * 2 because it's bytes + + if (length > 1) { + char charAt = buffer.readChar(length - 2); + amtToOverwrite += getPrintableCharacters(charAt); + + // delete last item in our buffer + length -= 2; + buffer.setPosition(length); + + // now figure out where the cursor is really at. + // this is more memory friendly than buf.toString.length + for (int i = 0; i < length; i += 2) { + charAt = buffer.readChar(i); + position += getPrintableCharacters(charAt); + } + + position++; + } + + overwrite = new char[amtToOverwrite]; + for (int i = 0; i < amtToOverwrite; i++) { + overwrite[i] = overWriteChar; + } + } + + if (Console.ENABLE_ANSI && overwrite != null) { + if (ansi == null) { + ansi = Ansi.ansi(); + } + + // move back however many, over write, then go back again + out.print(ansi.cursorToColumn(position)); + out.print(overwrite); + out.print(ansi.cursorToColumn(position)); + out.flush(); + + } + } + else if (asChar == '\n') { + // ignoring \r, because \n is ALWAYS the last character in a new line sequence. (even for windows, which we changed) + synchronized (inputLockLine) { + inputLockLine.notifyAll(); + } + } + else { + // only append if we are not a new line. + // our windows console PREVENTS us from returning '\r' (it truncates '\r\n', and returns just '\n') + for (ByteBuffer2 buffer : lineInputBuffers) { + buffer.writeChar(asChar); + } + } + } + } + + private static final int PLUS_TWO_MAYBE = 128 + 32; + private static final int PLUS_ONE = 128 + 127; + + /** + * Return the number of characters that will be printed when the specified character is echoed to the screen + *

+ * Adapted from cat by Torbjorn Granlund, as repeated in stty by David MacKenzie. + */ + private static + int getPrintableCharacters(final int ch) { + // StringBuilder sbuff = new StringBuilder(); + + if (ch >= 32) { + if (ch < 127) { + // sbuff.append((char) ch); + return 1; + } + else if (ch == 127) { + // sbuff.append('^'); + // sbuff.append('?'); + return 2; + } + else { + // sbuff.append('M'); + // sbuff.append('-'); + int count = 2; + + if (ch >= PLUS_TWO_MAYBE) { + if (ch < PLUS_ONE) { + // sbuff.append((char) (ch - 128)); + count++; + } + else { + // sbuff.append('^'); + // sbuff.append('?'); + count += 2; + } + } + else { + // sbuff.append('^'); + // sbuff.append((char) (ch - 128 + 64)); + count += 2; + } + return count; + } + } + else { + // sbuff.append('^'); + // sbuff.append((char) (ch + 64)); + return 2; + } + + // return sbuff; + } +} diff --git a/src/dorkbox/console/input/Terminal.java b/src/dorkbox/console/input/Terminal.java index aad3e05..8baa0c6 100644 --- a/src/dorkbox/console/input/Terminal.java +++ b/src/dorkbox/console/input/Terminal.java @@ -17,18 +17,27 @@ package dorkbox.console.input; import java.io.IOException; +import dorkbox.console.Console; + +@SuppressWarnings("unused") public abstract class Terminal { - public static final String CONSOLE_ERROR_INIT = "Unable to initialize the input console."; - protected static final int DEFAULT_WIDTH = 80; - protected static final int DEFAULT_HEIGHT = 24; - protected final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); + static final String CONSOLE_ERROR_INIT = "Unable to initialize the input console."; + + static final int DEFAULT_WIDTH = 80; + static final int DEFAULT_HEIGHT = 24; + final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(getClass()); - protected Terminal() { } + abstract + void doSetInterruptEnabled(final boolean enabled); + + protected abstract + void doSetEchoEnabled(final boolean enabled); + public abstract void restore() throws IOException; @@ -38,16 +47,72 @@ class Terminal { public abstract int getHeight(); - // NOT THREAD SAFE - public abstract - void setEchoEnabled(final boolean enabled); - - public abstract - void setInterruptEnabled(final boolean enabled); + /** + * Enables or disables CTRL-C behavior in the console + */ + public final + void setInterruptEnabled(final boolean enabled) { + Console.ENABLE_INTERRUPT = enabled; + doSetInterruptEnabled(enabled); + } /** - * @return a character from whatever underlying input method the terminal has available. + * Enables or disables character echo to stdout + */ + public final + void setEchoEnabled(final boolean enabled) { + Console.ENABLE_ECHO = enabled; + doSetEchoEnabled(enabled); + } + + /** + * Reads single character input from the console. + * + * @return -1 if no data or problems */ public abstract int read(); + + /** + * Reads a line of characters from the console as a character array, defined as everything before the 'ENTER' key is pressed + * + * @return empty char[] if no data + */ + public abstract + char[] readLineChars(); + + /** + * Reads a single line of characters, defined as everything before the 'ENTER' key is pressed + * @return null if no data + */ + public + String readLine() { + char[] line = readLineChars(); + if (line == null) { + return null; + } + return new String(line); + } + + /** + * Reads a line of characters from the console as a character array, defined as everything before the 'ENTER' key is pressed + * + * @return empty char[] if no data + */ + public + char[] readLinePassword() { + // don't bother in an IDE. it won't work. + boolean echoEnabled = Console.ENABLE_ECHO; + Console.ENABLE_ECHO = false; + char[] readLine0 = readLineChars(); + Console.ENABLE_ECHO = echoEnabled; + + return readLine0; + } + + /** + * releases any thread still waiting. + */ + public abstract + void close(); } diff --git a/src/dorkbox/console/input/UnsupportedTerminal.java b/src/dorkbox/console/input/UnsupportedTerminal.java index fb45c83..29902ac 100644 --- a/src/dorkbox/console/input/UnsupportedTerminal.java +++ b/src/dorkbox/console/input/UnsupportedTerminal.java @@ -18,19 +18,49 @@ package dorkbox.console.input; import java.io.IOException; import java.io.InputStream; +import dorkbox.util.FastThreadLocal; import dorkbox.util.bytes.ByteBuffer2; +@SuppressWarnings("Duplicates") public class UnsupportedTerminal extends Terminal { - private final ByteBuffer2 buffer = new ByteBuffer2(8, -1); - private final InputStream in = System.in; - private int readerCount = -1; + private static final char[] newLine; + static { + newLine = new char[1]; + newLine[0] = '\n'; + } + + private final FastThreadLocal buffer = new FastThreadLocal() { + @Override + public + ByteBuffer2 initialValue() { + return new ByteBuffer2(8, -1); + } + }; + + private final FastThreadLocal readCount = new FastThreadLocal() { + @Override + public + Integer initialValue() { + return 0; + } + }; public UnsupportedTerminal() { } + @Override + protected + void doSetInterruptEnabled(final boolean enabled) { + } + + @Override + protected + void doSetEchoEnabled(final boolean enabled) { + } + @Override public final void restore() { @@ -48,52 +78,99 @@ class UnsupportedTerminal extends Terminal { return 0; } - @Override + /** + * Reads single character input from the console. + * + * @return -1 if no data or problems + */ public - void setEchoEnabled(final boolean enabled) { - } - - @Override - public - void setInterruptEnabled(final boolean enabled) { - } - - @Override - public final int read() { - // if we are reading data (because we are in IDE mode), we want to return ALL the chars of the line! - - // so, 'readerCount' is REALLY the index at which we return letters (until the whole string is returned) - if (this.readerCount == -1) { + // so, 'readCount' is REALLY the index at which we return letters (until the whole string is returned) + ByteBuffer2 buffer = this.buffer.get(); + if (this.readCount.get() == 0) { // we have to wait for more data. try { - InputStream sysIn = this.in; + InputStream sysIn = System.in; int read; char asChar; - this.buffer.clearSecure(); + buffer.clearSecure(); while ((read = sysIn.read()) != -1) { asChar = (char) read; if (asChar == '\n') { - this.readerCount = this.buffer.position(); - this.buffer.rewind(); + int position = buffer.position(); + this.readCount.set(position); + if (position == 0) { + // only send a NEW LINE if it was the ONLY thing pressed (this is to MOST ACCURATELY simulate single char input + return '\n'; + } + + buffer.rewind(); break; } else { - this.buffer.writeChar(asChar); + buffer.writeChar(asChar); } } } catch (IOException ignored) { } } - // EACH thread will have it's own count! - if (this.readerCount == this.buffer.position()) { - this.readerCount = -1; - return '\n'; +// // EACH thread will have it's own count! +// if (this.readCount == buffer.position()) { +// this.readCount = -1; +// return '\n'; +// } +// else { +// return buffer.readChar(); +// } + readCount.set(this.readCount.get() - 2); // 2 bytes per char in the stream + return buffer.readChar(); + } + + /** + * Reads a line of characters from the console as a character array, defined as everything before the 'ENTER' key is pressed + * + * @return empty char[] if no data + */ + public + char[] readLineChars() { + ByteBuffer2 buffer = this.buffer.get(); + buffer.clearSecure(); + + int position = 0; + // we have to wait for more data. + try { + final InputStream sysIn = System.in; + int read; + char asChar; + + while ((read = sysIn.read()) != -1) { + asChar = (char) read; + + if (asChar == '\n') { + buffer.rewind(); + break; + } + buffer.writeChar(asChar); + position = buffer.position(); + } + } catch (IOException ignored) { } - else { - return this.buffer.readChar(); + + if (position == 0) { + // only send a NEW LINE if it was the ONLY thing pressed (this is to MOST ACCURATELY simulate single char input + return newLine; } + + char[] chars = buffer.readChars(position/2); // 2 bytes per char + buffer.clearSecure(); + + return chars; + } + + @Override + public + void close() { } } diff --git a/src/dorkbox/console/input/WindowsTerminal.java b/src/dorkbox/console/input/WindowsTerminal.java index e44868c..2a3b94b 100644 --- a/src/dorkbox/console/input/WindowsTerminal.java +++ b/src/dorkbox/console/input/WindowsTerminal.java @@ -35,7 +35,7 @@ import dorkbox.console.util.windows.Kernel32; * Terminal implementation for Microsoft Windows. */ public -class WindowsTerminal extends Terminal { +class WindowsTerminal extends SupportedTerminal { // Console mode constants copied wincon.h. // There are OTHER options, however they DO NOT work with unbuffered input or we just don't care about them. @@ -113,15 +113,15 @@ class WindowsTerminal extends Terminal { } @Override - public - void setEchoEnabled(final boolean enabled) { + protected + void doSetEchoEnabled(final boolean enabled) { // only way to do this, console modes DO NOT work echoEnabled = enabled; } @Override - public - void setInterruptEnabled(final boolean enabled) { + protected + void doSetInterruptEnabled(final boolean enabled) { IntByReference mode = new IntByReference(); GetConsoleMode(console, mode); @@ -138,8 +138,8 @@ class WindowsTerminal extends Terminal { } @Override - public final - int read() { + protected final + int doRead() { int input = readInput(); if (echoEnabled) { diff --git a/src/dorkbox/console/output/Ansi.java b/src/dorkbox/console/output/Ansi.java index efcd667..3627940 100644 --- a/src/dorkbox/console/output/Ansi.java +++ b/src/dorkbox/console/output/Ansi.java @@ -32,9 +32,16 @@ package dorkbox.console.output; import static dorkbox.console.output.AnsiOutputStream.ATTRIBUTE_RESET; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintStream; import java.util.ArrayList; import dorkbox.console.Console; +import dorkbox.console.util.posix.CLibraryPosix; +import dorkbox.console.util.windows.Kernel32; +import dorkbox.util.OS; /** * Provides a fluent API for generating ANSI escape sequences and providing access to streams that support it. @@ -44,12 +51,36 @@ import dorkbox.console.Console; * @author dorkbox, llc * @author Hiram Chirino */ -@SuppressWarnings("unused") +@SuppressWarnings({"unused", "WeakerAccess"}) public class Ansi { + private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Console.class); + + + private static final PrintStream original_out = System.out; + private static final PrintStream original_err = System.err; + + public static final PrintStream out = createPrintStream(original_out, 1); // STDOUT_FILENO; + public static final PrintStream err = createPrintStream(original_err, 2); // STDERR_FILENO + static { // make SURE that our console in/out/err are correctly setup BEFORE accessing methods in this class Console.getVersion(); + + System.setOut(out); + System.setErr(err); + + // don't forget we have to shut down the ansi console as well + Thread shutdownThread = new Thread() { + @Override + public + void run() { + // called when the JVM is shutting down. + restoreSystemStreams(); + } + }; + shutdownThread.setName("Console ANSI stream Shutdown"); + Runtime.getRuntime().addShutdownHook(shutdownThread); } private static final String NEW_LINE = System.getProperty("line.separator"); @@ -57,6 +88,17 @@ class Ansi { private final StringBuilder builder; private final ArrayList attributeOptions = new ArrayList(8); + + /** + * Restores System.err/out PrintStreams to their ORIGINAL configuration. Useful when using ANSI functionality but do not want to + * hook into the system. + */ + public static + void restoreSystemStreams() { + System.setOut(original_out); + System.setErr(original_err); + } + /** * Creates a new Ansi object */ @@ -872,4 +914,89 @@ class Ansi { builder.append(command); return this; } + + private static boolean isXterm() { + String term = System.getenv("TERM"); + return "xterm".equalsIgnoreCase(term); + } + + private static + PrintStream createPrintStream(final OutputStream stream, final int fileno) { + String type = fileno == 1 ? "OUT" : "ERR"; + + if (!Console.ENABLE_ANSI) { + // Use the ANSIOutputStream to strip out the ANSI escape sequences. + return getStripPrintStream(stream, type); + } + + if (!isXterm()) { + if (OS.isWindows()) { + // check if windows10+ (which natively supports ANSI) + if (Kernel32.isWindows10OrGreater()) { + // Just wrap it up so that when we get closed, we reset the attributes. + return defaultPrintStream(stream, type); + } + + // On windows we know the console does not interpret ANSI codes.. + try { + PrintStream printStream = new PrintStream(new WindowsAnsiOutputStream(stream, fileno)); + + if (logger.isDebugEnabled()) { + logger.debug("Created a Windows ANSI PrintStream for {}", type); + } + + return printStream; + } catch (Throwable ignore) { + // this happens when JNA is not in the path.. or + // this happens when the stdout is being redirected to a file. + // this happens when the stdout is being redirected to different console. + } + + // Use the ANSIOutputStream to strip out the ANSI escape sequences. + if (!Console.FORCE_ENABLE_ANSI) { + return getStripPrintStream(stream, type); + } + } else { + // We must be on some unix variant.. + try { + // If we can detect that stdout is not a tty.. then setup to strip the ANSI sequences.. + if (!Console.FORCE_ENABLE_ANSI && CLibraryPosix.isatty(fileno) == 0) { + return getStripPrintStream(stream, type); + } + } catch (Throwable ignore) { + // These errors happen if the JNI lib is not available for your platform. + } + } + } + + // By default we assume the terminal can handle ANSI codes. + // Just wrap it up so that when we get closed, we reset the attributes. + return defaultPrintStream(stream, type); + } + + private static + PrintStream getStripPrintStream(final OutputStream stream, final String type) { + if (logger.isDebugEnabled()) { + logger.debug("Created a strip-ANSI PrintStream for {}", type); + } + + return new PrintStream(new AnsiOutputStream(stream)); + } + + private static + PrintStream defaultPrintStream(final OutputStream stream, final String type) { + if (logger.isDebugEnabled()) { + logger.debug("Created ANSI PrintStream for {}", type); + } + + return new PrintStream(new FilterOutputStream(stream) { + @Override + public + void close() throws IOException { + write(AnsiOutputStream.RESET_CODE); + flush(); + super.close(); + } + }); + } }