1. Introduction
Lanterna is a library for building text-based user interfaces, giving us similar abilities to the Curses C library. However, Lanterna is written in pure Java. It also gives us the ability to generate a terminal UI even in a pure graphical environment by using an emulated terminal.
In this tutorial, we’re going to have a look at Lanterna. We’ll see what it is, what we can do with it, and how to use it.
2. Dependencies
Before using Lanterna, we need to include the latest version in our build, which is 3.1.2 at the time of writing.
If we’re using Maven, we can include this dependency in our pom.xml file:
<dependency>
<groupId>com.googlecode.lanterna</groupId>
<artifactId>lanterna</artifactId>
<version>3.1.2</version>
</dependency>
At this point, we’re ready to start using it in our application.
3. Accessing the Terminal
Before we can do terminal UI work, we need a terminal. This might be the actual system terminal that we’re running on, or a Swing frame that emulates one.
The safest way to access such a terminal is to use the DefaultTerminalFactory. This will do the best thing depending on the environment in which it’s running:
try (Terminal terminal = new DefaultTerminalFactory().createTerminal()) {
// Terminal functionality here
}
The way this works by default will vary between systems – either creating a terminal that works in terms of System.out and System.in, or creating an emulated terminal in a Swing or AWT frame. Regardless, we’ll always have a terminal that can render our UI.
Alternatively, we can create the exact type of terminal that we want by directly instantiating the appropriate class:
try (Terminal terminal = new SwingTerminalFrame() {
// Terminal functionality here
}
Lanterna provides several different implementations from which we can pick. However, it’s important that we select a suitable one or else it won’t work as desired – for example, the SwingTerminalFrame will only work in an environment that can run Swing applications.
Once created, we’ll probably want to activate private mode for the duration of our usage:
terminal.enterPrivateMode();
// Terminal functionality here
terminal.exitPrivateMode();
Private mode captures a copy of what the terminal was like and then clears it. This means we can manipulate the terminal however we need, and at the end, the terminal will return to its original state.
Note that we need to keep track of whether we’re in private mode or not. Entering or exiting private mode will throw an exception if we’re already in the desired state.
However, the close() method will correctly track if we’re in private mode or not and will exit it only if we were. This allows us to safely rely on the try-with-resources pattern to tidy up for us.
4. Low-Level Terminal Manipulation
Once we’ve got access to our terminal, we’re ready to start working with it.
The simplest thing we can do is to write characters to the terminal. We can do this with the putCharacter() method on the terminal:
terminal.putCharacter('H');
terminal.putCharacter('e');
terminal.putCharacter('l');
terminal.putCharacter('l');
terminal.putCharacter('o');
terminal.flush();
The flush() call is also needed to ensure that the characters are sent to the terminal. Without this, the underlying output stream will flush itself as it deems necessary, which can lead to the the terminal updating unexpectedly:
4.1. Cursor Position
When we enter private mode, Lanterna clears the screen and moves the cursor to the top left of the terminal. If we don’t use private mode then the cursor will remain where it was before. This prints characters at the current cursor location, and then the cursor moves one character to the right. If we reach the end of one line then this will wrap around to the next line.
If we want, we can manually position the cursor to wherever we want using the setCursorPosition() method:
terminal.setCursorPosition(10, 10);
terminal.putCharacter('H');
terminal.putCharacter('e');
terminal.putCharacter('l');
terminal.putCharacter('l');
terminal.putCharacter('o');
terminal.setCursorPosition(11, 11);
terminal.putCharacter('W');
terminal.putCharacter('o');
terminal.putCharacter('r');
terminal.putCharacter('l');
terminal.putCharacter('d');
As part of this, we need to know how big the terminal is. Lanterna gives us access to this with the getTerminalSize() method:
TerminalSize size = terminal.getTerminalSize();
System.out.println("Rows: " + size.getRows());
System.out.println("Columns: " + size.getColumns());
4.2. Text Styling
In addition to writing out characters, we also can do some level of styling.
*We can specify the colors of the characters using setForegroundColor() and setBackgroundColor(), providing the color to use:*
terminal.setForegroundColor(TextColor.ANSI.RED);
terminal.putCharacter('H');
terminal.putCharacter('e');
terminal.putCharacter('l');
terminal.putCharacter('l');
terminal.putCharacter('o');
terminal.setForegroundColor(TextColor.ANSI.DEFAULT);
terminal.setBackgroundColor(TextColor.ANSI.BLUE);
terminal.putCharacter('W');
terminal.putCharacter('o');
terminal.putCharacter('r');
terminal.putCharacter('l');
terminal.putCharacter('d');
These methods take the color to use. Using the provided enum of ANSI color names offers the safest way to set colors. We can also provide full RGB colors using the TextColor.RGB class. However, not all terminals support this, and using them on a terminal that doesn’t support it may provide undefined behavior.
We can also specify other styles, such as bold or underline. These are done using the enableSGR() and disableSGR() methods – to specify the SGR attributes to enable and disable for the printed characters:
terminal.enableSGR(SGR.BOLD);
terminal.putCharacter('H');
terminal.putCharacter('e');
terminal.putCharacter('l');
terminal.putCharacter('l');
terminal.putCharacter('o');
terminal.disableSGR(SGR.BOLD);
terminal.enableSGR(SGR.UNDERLINE);
terminal.putCharacter('W');
terminal.putCharacter('o');
terminal.putCharacter('r');
terminal.putCharacter('l');
terminal.putCharacter('d');
Finally, we can clear all of the colors and styles back to their defaults using the resetColorAndSGR() method. This will put everything back to how it was when the terminal was first opened.
4.3. Receiving Keyboard Input
As well as writing to our terminal, we’re also able to receive keyboard input from it. We have two different ways to achieve this. readInput() is a blocking call that will wait until a key press is received. Alternatively, pollInput() is a non-blocking alternative that returns the next key input available or null if nothing is available.
Both of these methods return a KeyStroke object representing the key that was pressed. We then need to use the getKeyType() method to determine the type of key that was pressed. If this is KeyType.Character, then it means one of the standard characters was pressed and we can determine which using getCharacter().
For example, let’s echo out the characters typed onto our terminal, stopping as soon as the Escape key is pressed:
while (true) {
KeyStroke keystroke = terminal.readInput();
if (keystroke.getKeyType() == KeyType.Escape) {
break;
} else if (keystroke.getKeyType() == KeyType.Character) {
terminal.putCharacter(keystroke.getCharacter());
terminal.flush();
}
}
In addition, we can detect if the Ctrl or Alt keys were pressed at the time of the keypress using the isCtrlDown() and isAltDown() methods. We can’t explicitly detect if the Shift key was down directly, but it will be reflected in the character returned by getCharacter().
5. Buffered Screen API
In addition to low-level access to the terminal, Lanterna also provides us with a buffered API to represent the screen as a whole. This doesn’t have the flexibility of using the lower-level APIs, but it’s much simpler for doing large-scale manipulations of the screen.
In order to work with this API, we first need to construct a Screen. We can either create it by directly wrapping a Terminal instance that we already have:
try (Screen screen = new TerminalScreen(terminal)) {
screen.startScreen();
// Screen functionality here
}
Or, if we haven’t already created a Terminal, then we can create the Screen directly from our DefaultTerminalFactory:
try (Screen screen = new DefaultTerminalFactory().createScreen()) {
screen.startScreen();
// Screen functionality here
}
In both of these cases, we’ve also had to call the startScreen() method. This will set up all of the required details, which includes moving the underlying terminal into private mode. Note that this means that we mustn’t have moved the terminal into private mode ourselves or else this will fail.
There’s also a corresponding stopScreen() method, but this will be called automatically by the close() method, so we can still rely on the try-by-resources pattern to tidy up for us.
Whenever we’re working with the Screen wrapper, this will keep track of everything that’s meant to be on the screen. This means that we shouldn’t manipulate it with the lower-level Terminal API at the same time, since it won’t understand these changes and we won’t get the desired results.
The fact that the Screen is buffered means that any changes that we make aren’t displayed straight away. Instead, our Screen object builds up a representation in memory as we’re going. We then need to use the refresh() method to write our entire screen out to the terminal.
5.1. Printing to the Screen
Once we’ve got a Screen instance, we can draw to it. Unlike the low-level API, we can draw entire formatted strings to the desired point in a single call.
For example, let’s draw a single character using the setCharacter() call:
screen.setCharacter(5, 5,
new TextCharacter('!',
TextColor.ANSI.RED, TextColor.ANSI.YELLOW_BRIGHT,
SGR.UNDERLINE, SGR.BOLD));
screen.refresh();
Here, we’ve provided the coordinates of the character, the character itself, the foreground and background colors, and any attributes to use:
Alternatively, let’s use a TextGraphics object to render multiple entire strings in the same styling:
TextGraphics text = screen.newTextGraphics();
text.setForegroundColor(TextColor.ANSI.RED);
text.setBackgroundColor(TextColor.ANSI.YELLOW_BRIGHT);
text.putString(5, 5, "Hello");
text.putString(6, 6, "World!");
screen.refresh();
Here, we’re generating a TextGraphics object with colors to use, and then using it to print entire strings to our screen directly:
5.2. Handling Screen Resizes
As with low-level rendering, it’s important that we know the size of the screen to be able to draw in the correct positions. However, in buffered mode, it’s also important that Lanterna knows this as well.
At the start of every rendering loop, we should call doResizeIfNecessary() to update the internal buffers. This will also return the new terminal size to us, or null if the terminal hasn’t changed size since our last check:
TerminalSize newSize = screen.doResizeIfNecessary();
if (newSize != null) {
// React to resize
}
This allows us to react to the screen resizing – for example, by clearing and redrawing the entire screen based on the new size.
6. Text GUIs
So far, we’ve seen how we can render our own text onto the terminal, either placing every character individually or treating the entire screen as a buffer to draw to. However, Lanterna also offers a layer on top of this where we can manage full text-based GUIs.
These GUIs are managed by the MultiWindowTextGUI class, which itself wraps a Screen instance:
MultiWindowTextGUI gui = new MultiWindowTextGUI(screen);
// Render GUI here
gui.updateScreen();
Unlike our Terminal and Screen classes, this doesn’t require any start or stop methods. Instead, it renders directly to the provided Screen when the updateScreen() method is called.
This, in turn, causes the screen to refresh, so we don’t need to manage that ourselves. However, we should only work with one TextGUI instance, otherwise, things will get out of sync.
6.1. Windows
Everything in our GUI is rendered inside a Window. The MultiWindowTextGUI is able to display multiple windows at a time, but by default, these will be modal and only the most recent one will be interactive.
Lanterna offers a number of different Window subclasses that we can work with. For example, let’s use a MessageDialog to display a simple message box to the user:
MessageDialog window = new MessageDialogBuilder()
.setTitle("Message Dialog")
.setText("Dialog Contents")
.build();
gui.addWindow(window);
Once we create our window and add it to the GUI with the addWindow() call, Lanterna will render it correctly:
The most flexible window that we can use is the BasicWindow:
BasicWindow window = new BasicWindow("Basic Window");
gui.addWindow(window);
This has no predefined contents or behavior and instead allows us to define all of that ourselves.
By default, our windows will all look a certain way. The windows will have a border, cast a shadow on background elements, size themselves to fit their contents, and cascade from the top of the screen. However, we can provide hints to Lanterna to override all of these:
BasicWindow window = new BasicWindow("Basic Window");
window.setHints(Set.of(Window.Hint.CENTERED,
Window.Hint.NO_POST_RENDERING,
Window.Hint.EXPANDED));
gui.addWindow(window);
This will center the window in the GUI, expand it to fill most (but not all) of the screen, and prevent it from casting a shadow on background elements:
6.2. Components
Now that we’ve got a window in our GUI, we need to be able to add to it. Lanterna offers a range of components that we can add to our windows – including labels, buttons, text boxes, and many more.
Our window can have exactly one component added to it, with the setComponent() method. This takes the component that we want to use:
window.setComponent(new Label("This is a label"));
This is all that’s necessary for the new component to render:
If we haven’t given our window any hints about its size, it’ll automatically size to fit this component.
However, being able to add only a single component to our window is quite limiting. Lantera addresses this in a similar way to AWT/Swing. We can add a Panel component and configure it with a LayoutManager to arrange multiple components in our desired layout:
BasicWindow window = new BasicWindow("Basic Window");
Panel innerPanel = new Panel(new LinearLayout(Direction.HORIZONTAL));
innerPanel.addComponent(new Label("Left"));
innerPanel.addComponent(new Label("Middle"));
innerPanel.addComponent(new Label("Right"));
Panel outerPanel = new Panel(new LinearLayout(Direction.VERTICAL));
outerPanel.addComponent(new Label("Top"));
outerPanel.addComponent(innerPanel);
outerPanel.addComponent(new Label("Bottom"));
window.setComponent(outerPanel);
This gives us a panel with three components laid out vertically – two labels and another panel that, itself, has three labels laid out horizontally:
6.3. Interactive Components
So far, all of our components have been passive ones. However, Lanterna gives us a range of interactive components as well – including text boxes, buttons, and more.
Some components are automatically interactive in their own rights – for example, text boxes will allow us to type into them and correctly update themselves. Other components, such as buttons, allow us to add listeners to react to the user input:
TextBox textbox = new TextBox();
Button button = new Button("OK");
button.addListener((b) -> {
System.out.println(textbox.getText());
window.close();
});
Panel panel = new Panel(new LinearLayout(Direction.VERTICAL));
panel.addComponent(textbox);
panel.addComponent(button);
This will give us a text box and a button. Activating the button will then print out the value typed into the textbox and close the window:
*For our window to handle input, we need to call waitUntilClosed(). At this point, Lanterna will handle keyboard input in the focused components and let the user interact with it.* Note that this method will block until the window closes, which means that we need to set up any appropriate handlers first.
7. Conclusion
This was a quick introduction to Lanterna. There’s a lot more that can be done with this library, including many more GUI components. Next time you need to build a text-based UI, why not give it a try?
As usual, all of the examples from this article are available over on GitHub.