/*
* This file is part of lanterna (http://code.google.com/p/lanterna/).
*
* lanterna is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright (C) 2010-2017 Martin Berglund
*/
package com.googlecode.lanterna.screen;
import com.googlecode.lanterna.*;
import com.googlecode.lanterna.graphics.Scrollable;
import com.googlecode.lanterna.input.KeyStroke;
import com.googlecode.lanterna.input.KeyType;
import com.googlecode.lanterna.terminal.Terminal;
import com.googlecode.lanterna.terminal.TerminalResizeListener;
import java.io.IOException;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.Map;
import java.util.TreeMap;
/**
* This is the default concrete implementation of the Screen interface, a buffered layer sitting on top of a Terminal.
* If you want to get started with the Screen layer, this is probably the class you want to use. Remember to start the
* screen before you can use it and stop it when you are done with it. This will place the terminal in private mode
* during the screen operations and leave private mode afterwards.
* @author martin
*/
public class TerminalScreen extends AbstractScreen {
private final Terminal terminal;
private boolean isStarted;
private boolean fullRedrawHint;
private ScrollHint scrollHint;
/**
* Creates a new Screen on top of a supplied terminal, will query the terminal for its size. The screen is initially
* blank. The default character used for unused space (the newly initialized state of the screen and new areas after
* expanding the terminal size) will be a blank space in 'default' ANSI front- and background color.
* <p>
* Before you can display the content of this buffered screen to the real underlying terminal, you must call the
* {@code startScreen()} method. This will ask the terminal to enter private mode (which is required for Screens to
* work properly). Similarly, when you are done, you should call {@code stopScreen()} which will exit private mode.
*
* @param terminal Terminal object to create the DefaultScreen on top of
* @throws java.io.IOException If there was an underlying I/O error when querying the size of the terminal
*/
public TerminalScreen(Terminal terminal) throws IOException {
this(terminal, DEFAULT_CHARACTER);
}
/**
* Creates a new Screen on top of a supplied terminal, will query the terminal for its size. The screen is initially
* blank. The default character used for unused space (the newly initialized state of the screen and new areas after
* expanding the terminal size) will be a blank space in 'default' ANSI front- and background color.
* <p>
* Before you can display the content of this buffered screen to the real underlying terminal, you must call the
* {@code startScreen()} method. This will ask the terminal to enter private mode (which is required for Screens to
* work properly). Similarly, when you are done, you should call {@code stopScreen()} which will exit private mode.
*
* @param terminal Terminal object to create the DefaultScreen on top of.
* @param defaultCharacter What character to use for the initial state of the screen and expanded areas
* @throws java.io.IOException If there was an underlying I/O error when querying the size of the terminal
*/
public TerminalScreen(Terminal terminal, TextCharacter defaultCharacter) throws IOException {
super(terminal.getTerminalSize(), defaultCharacter);
this.terminal = terminal;
this.terminal.addResizeListener(new TerminalScreenResizeListener());
this.isStarted = false;
this.fullRedrawHint = true;
}
@Override
public synchronized void startScreen() throws IOException {
if(isStarted) {
return;
}
isStarted = true;
getTerminal().enterPrivateMode();
getTerminal().getTerminalSize();
getTerminal().clearScreen();
this.fullRedrawHint = true;
TerminalPosition cursorPosition = getCursorPosition();
if(cursorPosition != null) {
getTerminal().setCursorVisible(true);
getTerminal().setCursorPosition(cursorPosition.getColumn(), cursorPosition.getRow());
} else {
getTerminal().setCursorVisible(false);
}
}
@Override
public void stopScreen() throws IOException {
stopScreen(true);
}
public synchronized void stopScreen(boolean flushInput) throws IOException {
if(!isStarted) {
return;
}
if (flushInput) {
//Drain the input queue
KeyStroke keyStroke;
do {
keyStroke = pollInput();
}
while(keyStroke != null && keyStroke.getKeyType() != KeyType.EOF);
}
getTerminal().exitPrivateMode();
isStarted = false;
}
@Override
public synchronized void refresh(RefreshType refreshType) throws IOException {
if(!isStarted) {
return;
}
if((refreshType == RefreshType.AUTOMATIC && fullRedrawHint) || refreshType == RefreshType.COMPLETE) {
refreshFull();
fullRedrawHint = false;
}
else if(refreshType == RefreshType.AUTOMATIC &&
(scrollHint == null || scrollHint == ScrollHint.INVALID)) {
double threshold = getTerminalSize().getRows() * getTerminalSize().getColumns() * 0.75;
if(getBackBuffer().isVeryDifferent(getFrontBuffer(), (int) threshold)) {
refreshFull();
}
else {
refreshByDelta();
}
}
else {
refreshByDelta();
}
getBackBuffer().copyTo(getFrontBuffer());
TerminalPosition cursorPosition = getCursorPosition();
if(cursorPosition != null) {
getTerminal().setCursorVisible(true);
//If we are trying to move the cursor to the padding of a CJK character, put it on the actual character instead
if(cursorPosition.getColumn() > 0 && TerminalTextUtils.isCharCJK(getFrontBuffer().getCharacterAt(cursorPosition.withRelativeColumn(-1)).getCharacter())) {
getTerminal().setCursorPosition(cursorPosition.getColumn() - 1, cursorPosition.getRow());
}
else {
getTerminal().setCursorPosition(cursorPosition.getColumn(), cursorPosition.getRow());
}
} else {
getTerminal().setCursorVisible(false);
}
getTerminal().flush();
}
private void useScrollHint() throws IOException {
if (scrollHint == null) { return; }
try {
if (scrollHint == ScrollHint.INVALID) { return; }
Terminal term = getTerminal();
if (term instanceof Scrollable) {
// just try and see if it cares:
scrollHint.applyTo( (Scrollable)term );
// if that didn't throw, then update front buffer:
scrollHint.applyTo( getFrontBuffer() );
}
}
catch (UnsupportedOperationException uoe) { /* ignore */ }
finally { scrollHint = null; }
}
private void refreshByDelta() throws IOException {
Map<TerminalPosition, TextCharacter> updateMap = new TreeMap<TerminalPosition, TextCharacter>(new ScreenPointComparator());
TerminalSize terminalSize = getTerminalSize();
useScrollHint();
for(int y = 0; y < terminalSize.getRows(); y++) {
for(int x = 0; x < terminalSize.getColumns(); x++) {
TextCharacter backBufferCharacter = getBackBuffer().getCharacterAt(x, y);
if(!backBufferCharacter.equals(getFrontBuffer().getCharacterAt(x, y))) {
updateMap.put(new TerminalPosition(x, y), backBufferCharacter);
}
if(TerminalTextUtils.isCharCJK(backBufferCharacter.getCharacter())) {
x++; //Skip the trailing padding
}
}
}
if(updateMap.isEmpty()) {
return;
}
TerminalPosition currentPosition = updateMap.keySet().iterator().next();
getTerminal().setCursorPosition(currentPosition.getColumn(), currentPosition.getRow());
TextCharacter firstScreenCharacterToUpdate = updateMap.values().iterator().next();
EnumSet<SGR> currentSGR = firstScreenCharacterToUpdate.getModifiers();
getTerminal().resetColorAndSGR();
for(SGR sgr: currentSGR) {
getTerminal().enableSGR(sgr);
}
TextColor currentForegroundColor = firstScreenCharacterToUpdate.getForegroundColor();
TextColor currentBackgroundColor = firstScreenCharacterToUpdate.getBackgroundColor();
getTerminal().setForegroundColor(currentForegroundColor);
getTerminal().setBackgroundColor(currentBackgroundColor);
for(TerminalPosition position: updateMap.keySet()) {
if(!position.equals(currentPosition)) {
getTerminal().setCursorPosition(position.getColumn(), position.getRow());
currentPosition = position;
}
TextCharacter newCharacter = updateMap.get(position);
if(!currentForegroundColor.equals(newCharacter.getForegroundColor())) {
getTerminal().setForegroundColor(newCharacter.getForegroundColor());
currentForegroundColor = newCharacter.getForegroundColor();
}
if(!currentBackgroundColor.equals(newCharacter.getBackgroundColor())) {
getTerminal().setBackgroundColor(newCharacter.getBackgroundColor());
currentBackgroundColor = newCharacter.getBackgroundColor();
}
for(SGR sgr: SGR.values()) {
if(currentSGR.contains(sgr) && !newCharacter.getModifiers().contains(sgr)) {
getTerminal().disableSGR(sgr);
currentSGR.remove(sgr);
}
else if(!currentSGR.contains(sgr) && newCharacter.getModifiers().contains(sgr)) {
getTerminal().enableSGR(sgr);
currentSGR.add(sgr);
}
}
getTerminal().putCharacter(newCharacter.getCharacter());
if(TerminalTextUtils.isCharCJK(newCharacter.getCharacter())) {
//CJK characters advances two columns
currentPosition = currentPosition.withRelativeColumn(2);
}
else {
//Normal characters advances one column
currentPosition = currentPosition.withRelativeColumn(1);
}
}
}
private void refreshFull() throws IOException {
getTerminal().setForegroundColor(TextColor.ANSI.DEFAULT);
getTerminal().setBackgroundColor(TextColor.ANSI.DEFAULT);
getTerminal().clearScreen();
getTerminal().resetColorAndSGR();
scrollHint = null; // discard any scroll hint for full refresh
EnumSet<SGR> currentSGR = EnumSet.noneOf(SGR.class);
TextColor currentForegroundColor = TextColor.ANSI.DEFAULT;
TextColor currentBackgroundColor = TextColor.ANSI.DEFAULT;
for(int y = 0; y < getTerminalSize().getRows(); y++) {
getTerminal().setCursorPosition(0, y);
int currentColumn = 0;
for(int x = 0; x < getTerminalSize().getColumns(); x++) {
TextCharacter newCharacter = getBackBuffer().getCharacterAt(x, y);
if(newCharacter.equals(DEFAULT_CHARACTER)) {
continue;
}
if(!currentForegroundColor.equals(newCharacter.getForegroundColor())) {
getTerminal().setForegroundColor(newCharacter.getForegroundColor());
currentForegroundColor = newCharacter.getForegroundColor();
}
if(!currentBackgroundColor.equals(newCharacter.getBackgroundColor())) {
getTerminal().setBackgroundColor(newCharacter.getBackgroundColor());
currentBackgroundColor = newCharacter.getBackgroundColor();
}
for(SGR sgr: SGR.values()) {
if(currentSGR.contains(sgr) && !newCharacter.getModifiers().contains(sgr)) {
getTerminal().disableSGR(sgr);
currentSGR.remove(sgr);
}
else if(!currentSGR.contains(sgr) && newCharacter.getModifiers().contains(sgr)) {
getTerminal().enableSGR(sgr);
currentSGR.add(sgr);
}
}
if(currentColumn != x) {
getTerminal().setCursorPosition(x, y);
currentColumn = x;
}
getTerminal().putCharacter(newCharacter.getCharacter());
if(TerminalTextUtils.isCharCJK(newCharacter.getCharacter())) {
//CJK characters take up two columns
currentColumn += 2;
x++;
}
else {
//Normal characters take up one column
currentColumn += 1;
}
}
}
}
/**
* Returns the underlying {@code Terminal} interface that this Screen is using.
* <p>
* <b>Be aware:</b> directly modifying the underlying terminal will most likely result in unexpected behaviour if
* you then go on and try to interact with the Screen. The Screen's back-buffer/front-buffer will not know about
* the operations you are going on the Terminal and won't be able to properly generate a refresh unless you enforce
* a {@code Screen.RefreshType.COMPLETE}, at which the entire terminal area will be repainted according to the
* back-buffer of the {@code Screen}.
* @return Underlying terminal used by the screen
*/
@SuppressWarnings("WeakerAccess")
public Terminal getTerminal() {
return terminal;
}
@Override
public KeyStroke readInput() throws IOException {
return terminal.readInput();
}
@Override
public KeyStroke pollInput() throws IOException {
return terminal.pollInput();
}
@Override
public synchronized void clear() {
super.clear();
fullRedrawHint = true;
scrollHint = ScrollHint.INVALID;
}
@Override
public synchronized TerminalSize doResizeIfNecessary() {
TerminalSize newSize = super.doResizeIfNecessary();
if(newSize != null) {
fullRedrawHint = true;
}
return newSize;
}
/**
* Perform the scrolling and save scroll-range and distance in order
* to be able to optimize Terminal-update later.
*/
@Override
public void scrollLines(int firstLine, int lastLine, int distance) {
// just ignore certain kinds of garbage:
if (distance == 0 || firstLine > lastLine) { return; }
super.scrollLines(firstLine, lastLine, distance);
// Save scroll hint for next refresh:
ScrollHint newHint = new ScrollHint(firstLine,lastLine,distance);
if (scrollHint == null) {
// no scroll hint yet: use the new one:
scrollHint = newHint;
} else //noinspection StatementWithEmptyBody
if (scrollHint == ScrollHint.INVALID) {
// scroll ranges already inconsistent since latest refresh!
// leave at INVALID
} else if (scrollHint.matches(newHint)) {
// same range: just accumulate distance:
scrollHint.distance += newHint.distance;
} else {
// different scroll range: no scroll-optimization for next refresh
this.scrollHint = ScrollHint.INVALID;
}
}
private class TerminalScreenResizeListener implements TerminalResizeListener {
@Override
public void onResized(Terminal terminal, TerminalSize newSize) {
addResizeRequest(newSize);
}
}
private static class ScreenPointComparator implements Comparator<TerminalPosition> {
@Override
public int compare(TerminalPosition o1, TerminalPosition o2) {
if(o1.getRow() == o2.getRow()) {
if(o1.getColumn() == o2.getColumn()) {
return 0;
} else {
return new Integer(o1.getColumn()).compareTo(o2.getColumn());
}
} else {
return new Integer(o1.getRow()).compareTo(o2.getRow());
}
}
}
private static class ScrollHint {
public static final ScrollHint INVALID = new ScrollHint(-1,-1,0);
public final int firstLine;
public final int lastLine;
public int distance;
public ScrollHint(int firstLine, int lastLine, int distance) {
this.firstLine = firstLine;
this.lastLine = lastLine;
this.distance = distance;
}
public boolean matches(ScrollHint other) {
return this.firstLine == other.firstLine
&& this.lastLine == other.lastLine;
}
public void applyTo( Scrollable scr ) throws IOException {
scr.scrollLines(firstLine, lastLine, distance);
}
}
}