Skip to content

Commit

Permalink
Rework of the library (#115)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexarchambault authored Jan 9, 2025
1 parent b1d63a9 commit 6a4f810
Show file tree
Hide file tree
Showing 15 changed files with 533 additions and 221 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ out/
.bsp/
.metals/
.vscode/
.idea/
56 changes: 20 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,67 +1,51 @@
# native-terminal

# windows-ansi
[![Build status](https://github.com/alexarchambault/native-terminal/workflows/CI/badge.svg)](https://github.com/alexarchambault/native-terminal/actions?query=workflow%3ACI)
[![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.native-terminal/native-terminal.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.native-terminal/native-terminal)

[![Build status](https://github.com/alexarchambault/windows-ansi/workflows/CI/badge.svg)](https://github.com/alexarchambault/windows-ansi/actions?query=workflow%3ACI)
[![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.windows-ansi/windows-ansi.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.windows-ansi/windows-ansi)

*windows-ansi* is a small Java library to setup / interact with a Windows terminal. It allows to
*native-terminal* is a small Java library to setup / interact with a terminals in a native fashion. It allows to
- query the terminal size, and
- change the console mode so that it accepts ANSI escape codes.
- on Windows, change the console mode so that it accepts ANSI escape codes.

It relies on internals of the [jansi](https://github.com/fusesource/jansi) library to do so, and also works from
GraalVM native images.

Compared to using [jline](https://github.com/jline/jline3), *windows-ansi* only and solely calls the right
`kernel32.dll` system calls (like [`SetConsoleMode`](https://docs.microsoft.com/en-us/windows/console/setconsolemode)
or [`GetConsoleScreenBufferInfo`](https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo)), lowering the odds of something going wrong when generating or using a GraalVM native image for example.
Compared to using [jline](https://github.com/jline/jline3), *native-terminal* only and solely calls the right
`ioctl` system calls
(or `kernel32.dll` system calls, like [`SetConsoleMode`](https://docs.microsoft.com/en-us/windows/console/setconsolemode)
or [`GetConsoleScreenBufferInfo`](https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo), on Windows),
lowering the odds of something going wrong when generating or using a GraalVM native image for example.

## Usage

Add to your `build.sbt`
```scala
libraryDependencies += "io.github.alexarchambault.windows-ansi" % "windows-ansi" % "0.0.1"
```

The latest version is [![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.windows-ansi/windows-ansi.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.windows-ansi/windows-ansi).

The `WindowsAnsi` methods should only be called from Windows. You can check that
the current application is running on Windows with:
```java
boolean isWindows = System.getProperty("os.name")
.toLowerCase(java.util.Locale.ROOT)
.contains("windows");
libraryDependencies += "io.github.alexarchambault.native-terminal" % "native-terminal" % "0.0.7"
```

Alternatively, when using Graal native image, the following should work too, and has the benefit of simply
discarding one of the `if` branches at image generation time:
```java
// requires the org.graalvm.nativeimage:svm dependency,
// which can usually be marked as "provided"
if (com.oracle.svm.core.os.IsDefined.WIN32()) {
// call io.github.alexarchambault.windowsansi.WindowsAnsi methods
} else {
// not on Windows, handle things like you would on Unixes
}
```
The latest version is [![Maven Central](https://img.shields.io/maven-central/v/io.github.alexarchambault.native-terminal/native-terminal.svg)](https://maven-badges.herokuapp.com/maven-central/io.github.alexarchambault.native-terminal/native-terminal).

### Change terminal mode

Change the terminal mode so that it accepts ANSI escape codes with
Change the terminal mode on Windows, so that it accepts ANSI escape codes with
```java
import io.github.alexarchambault.windowsansi.WindowsAnsi;
import io.github.alexarchambault.nativeterm.NativeTerminal;

boolean success = WindowsAnsi.setup();
boolean success = NativeTerminal.setupAnsi();
```

A returned value of `false` means ANSI escape codes aren't supported by the Windows version you're running on.
These are supposed to be supported by Windows 10 build 10586 (Nov. 2015) onwards.

Calling this method is safe on other platforms. It simply returns true in that case.

### Get terminal size

```java
import io.github.alexarchambault.windowsansi.WindowsAnsi;
import io.github.alexarchambault.nativeterm.NativeTerminal;
import io.github.alexarchambault.nativeterm.TerminalSize;

WindowsAnsi.Size size = WindowsAnsi.terminalSize();
TerminalSize size = NativeTerminal.size();
int width = size.getWidth();
int height = size.getHeight();
```
Expand Down
21 changes: 11 additions & 10 deletions build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import mill.scalalib.publish._
trait WindowsAnsiPublishModule extends PublishModule with Mima {
def pomSettings = PomSettings(
description = artifactName(),
organization = "io.github.alexarchambault.windows-ansi",
url = "https://github.com/alexarchambault/windows-ansi",
organization = "io.github.alexarchambault.native-terminal",
url = "https://github.com/alexarchambault/native-terminal",
licenses = Seq(
License.`Apache-2.0`,
License.`GPL-2.0-with-classpath-exception`
),
versionControl = VersionControl.github("alexarchambault", "windows-ansi"),
versionControl = VersionControl.github("alexarchambault", "native-terminal"),
developers = Seq(
Developer("alexarchambault", "Alex Archambault", "https://github.com/alexarchambault")
)
Expand Down Expand Up @@ -60,16 +60,17 @@ trait WindowsAnsiPublishModule extends PublishModule with Mima {
}
}

object jni extends JavaModule with WindowsAnsiPublishModule {
def artifactName = "windows-ansi"
object native extends JavaModule with WindowsAnsiPublishModule {
def artifactName = "native-terminal"
def ivyDeps = Agg(
ivy"io.github.alexarchambault:is-terminal:0.1.1",
ivy"org.fusesource.jansi:jansi:2.4.1"
)
}

object `jni-graalvm` extends JavaModule with WindowsAnsiPublishModule {
def moduleDeps = Seq(jni)
def artifactName = "windows-ansi-graalvm"
object `native-graalvm` extends JavaModule with WindowsAnsiPublishModule {
def moduleDeps = Seq(native)
def artifactName = "native-terminal-graalvm"
def pomSettings = super.pomSettings().copy(
licenses = Seq(License.`GPL-2.0-with-classpath-exception`)
)
Expand All @@ -84,8 +85,8 @@ object `jni-graalvm` extends JavaModule with WindowsAnsiPublishModule {
}
}

object ps extends JavaModule with WindowsAnsiPublishModule {
def artifactName = "windows-ansi-ps"
object fallbacks extends JavaModule with WindowsAnsiPublishModule {
def artifactName = "native-terminal-fallbacks"
def mimaPreviousVersions = {
val publishedSince = coursier.core.Version("0.0.2")
super.mimaPreviousVersions().dropWhile { v =>
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ final class NativeImageFeature implements Feature {
private static final List<String> JNI_CLASS_NAMES = Arrays.asList(
"org.fusesource.jansi.internal.CLibrary",
"org.fusesource.jansi.internal.CLibrary$WinSize",
"org.fusesource.jansi.internal.CLibrary$Termios",
"org.fusesource.jansi.internal.CLibrary$Termios");
private static final List<String> WINDOWS_JNI_CLASS_NAMES = Arrays.asList(
"org.fusesource.jansi.internal.Kernel32",
"org.fusesource.jansi.internal.Kernel32$SMALL_RECT",
"org.fusesource.jansi.internal.Kernel32$COORD",
Expand All @@ -70,34 +71,56 @@ final class NativeImageFeature implements Feature {
@Override
public void beforeAnalysis(BeforeAnalysisAccess access) {

if (Platform.includedIn(Platform.WINDOWS.class)) {
/*
* Each listed class has a native method named "init" that initializes all declared
* fields of the class using JNI. So when the "init" method gets reachable (which means
* the class initializer got reachable), we need to register all fields for JNI access.
*/
JNI_CLASS_NAMES.stream()
.map(access::findClassByName)
.filter(Objects::nonNull)
.map(jniClass -> ReflectionUtil.lookupMethod(jniClass, "init"))
.forEach(initMethod -> access.registerReachabilityHandler(a -> registerJNIFields(initMethod), initMethod));

/*
* Each listed class has a native method named "init" that initializes all declared
* fields of the class using JNI. So when the "init" method gets reachable (which means
* the class initializer got reachable), we need to register all fields for JNI access.
*/
JNI_CLASS_NAMES.stream()
if (Platform.includedIn(Platform.WINDOWS.class)) {
WINDOWS_JNI_CLASS_NAMES.stream()
.map(access::findClassByName)
.filter(Objects::nonNull)
.map(jniClass -> ReflectionUtil.lookupMethod(jniClass, "init"))
.forEach(initMethod -> access.registerReachabilityHandler(a -> registerJNIFields(initMethod), initMethod));
}
}

private AtomicBoolean resourceRegistered = new AtomicBoolean();
private final AtomicBoolean resourceRegistered = new AtomicBoolean();

private void registerJNIFields(Method initMethod) {
Class<?> jniClass = initMethod.getDeclaringClass();
JNIRuntimeAccess.register(jniClass.getDeclaredFields());

if (!resourceRegistered.getAndSet(true)) {
/* The native library that is included as a resource in the .jar file. */
String resource = "META-INF/native/windows64/jansi.dll";
InputStream is = jniClass.getClassLoader().getResourceAsStream(resource);
if (is == null)
throw new RuntimeException("Could not find resource " + resource);
Resources.registerResource(resource, is);
String resource = null;
if (Platform.includedIn(Platform.WINDOWS_AMD64.class))
resource = "Windows/x86_64/jansi.dll";
else if (Platform.includedIn(Platform.WINDOWS_AARCH64.class))
// FIXME File name should be changed to jansi.dll in the upcoming jansi version
resource = "Windows/arm64/libjansi.so";
else if (Platform.includedIn(Platform.LINUX_AMD64.class))
resource = "Linux/x86_64/libjansi.so";
else if (Platform.includedIn(Platform.LINUX_AARCH64.class))
resource = "Linux/arm64/libjansi.so";
else if (Platform.includedIn(Platform.DARWIN_AMD64.class))
resource = "Mac/x86_64/libjansi.jnilib";
else if (Platform.includedIn(Platform.DARWIN_AARCH64.class))
resource = "Mac/arm64/libjansi.jnilib";

if (resource != null) {
resource = "org/fusesource/jansi/internal/native/" + resource;
InputStream is = jniClass.getClassLoader().getResourceAsStream(resource);
if (is == null)
throw new RuntimeException("Could not find resource " + resource);
Resources.registerResource(resource, is);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.github.alexarchambault.windowsansi;

/**
* Empty public class to make javadoc happy
*/
public final class Placeholder {
private Placeholder() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.github.alexarchambault.nativeterm;

import io.github.alexarchambault.isterminal.IsTerminal;
import io.github.alexarchambault.nativeterm.internal.UnixTerm;
import io.github.alexarchambault.nativeterm.internal.WindowsTerm;

import java.io.IOException;

/**
* Use these methods to query the terminal size and enable ANSI output in it
*/
public final class NativeTerminal {
private NativeTerminal() {}

final static boolean isWindows;

static {
isWindows = System.getProperty("os.name")
.toLowerCase(java.util.Locale.ROOT)
.contains("windows");
}

/**
* Gets the terminal size
*
* This uses an {@code ioctl} call on Linux / Mac, and a {@code kernel32.dll} method
* on Windows. Both are done via JNI, using libraries that ship with jansi.
*
* @return the terminal size
*/
public static TerminalSize getSize() {
if (isWindows)
return WindowsTerm.getSize();
return UnixTerm.getSize(true);
}

/**
* Enables ANSI terminal output (only needed on Windows)
*
* It is safe to call this method on non-Windows systems. It simply
* returns true in that case.
*
* Under-the-hood, this calls a {@code kernel32.dll} method, via JNI,
* using libraries that ship with jansi.
*
* @throws IOException if anything goes wrong
* @return Whether ANSI output is enabled
*/
public static boolean setupAnsi() throws IOException {
if (isWindows && IsTerminal.isTerminal())
return WindowsTerm.setupAnsi();
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.github.alexarchambault.nativeterm;

import io.github.alexarchambault.isterminal.IsTerminal;
import io.github.alexarchambault.nativeterm.internal.ScriptRunner;
import io.github.alexarchambault.nativeterm.internal.WindowsTermScripts;

import java.io.IOException;

import static io.github.alexarchambault.nativeterm.NativeTerminal.isWindows;

/**
* Methods to query the terminal size and enable ANSI output in it like in {@link NativeTerminal}
*/
public final class NativeTerminalFallbacks {

private NativeTerminalFallbacks() {}

/**
* Gets the terminal size
*
* This runs {@code tput} via an external process on Linux / Mac, and runs
* a PowerShell script that calls {@code kernel32.dll} methods on Windows
*
* @throws InterruptedException if the PowerShell sub-process gets interrupted
* @throws IOException if anything goes wrong
* @return the terminal size
*/
public static TerminalSize getSize() throws InterruptedException, IOException {
if (!IsTerminal.isTerminal())
throw new IllegalArgumentException("Cannot get terminal size without a terminal");
if (isWindows) {
String output = ScriptRunner.runPowerShellScript(WindowsTermScripts.getConsoleDimScript).trim();
String[] lines = output.split("\\r?\\n");
String lastLine = lines[lines.length - 1];
if (lastLine.startsWith("Error:"))
throw new IOException(lastLine);
if (lastLine.startsWith("Size: ")) {
lastLine = lastLine.substring("Size: ".length());
String[] split = lastLine.split("\\s+");
return new TerminalSize(Integer.parseInt(split[0]), Integer.parseInt(split[1]));
}
throw new IOException("Invalid output from PowerShell script that gets terminal size: '" + output + "'");
}
else {
return new TerminalSize(ScriptRunner.runTput("cols"), ScriptRunner.runTput("lines"));
}
}

/**
* Enables ANSI terminal output (only needed on Windows)
*
* It is safe to call this method on non-Windows systems. It simply
* returns true in that case.
*
* Under-the-hood, this calls a {@code kernel32.dll} method, via a PowerShell script,
* run in an external process.
*
* @throws IOException if anything goes wrong
* @throws InterruptedException if the PowerShell sub-process gets interrupted
* @return Whether ANSI output is enabled
*/
public static boolean setupAnsi() throws InterruptedException, IOException {
if (isWindows && IsTerminal.isTerminal()) {
String output = ScriptRunner.runPowerShellScript(WindowsTermScripts.enableAnsiScript);
String[] lines = output.split("\\r?\\n");
String lastLine = lines[lines.length - 1];
return Boolean.parseBoolean(lastLine);
}
return true;
}
}
Loading

0 comments on commit 6a4f810

Please sign in to comment.