LineBuffer.java
/*
* Copyright 2021 Terminal Utils Authors
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 net.morimekta.terminal;
import net.morimekta.collect.UnmodifiableList;
import net.morimekta.strings.EscapeUtil;
import net.morimekta.terminal.progress.ProgressLine;
import net.morimekta.terminal.selection.Selection;
import java.io.Closeable;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import java.util.Objects;
import java.util.RandomAccess;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import static net.morimekta.strings.StringUtil.printableWidth;
import static net.morimekta.strings.chr.Char.CR;
import static net.morimekta.strings.chr.Char.LF;
import static net.morimekta.strings.chr.Control.CURSOR_ERASE;
import static net.morimekta.strings.chr.Control.UP;
import static net.morimekta.strings.chr.Control.cursorDown;
import static net.morimekta.strings.chr.Control.cursorRight;
import static net.morimekta.strings.chr.Control.cursorUp;
/**
* Class that holds a set of lines, that are printed to the terminal, and
* methods to dynamically update those buffer. It will keep the cursor at
* the bottom line (end of printed line) for easy continuation.
* <p>
* The class acts as a wrapper around a {@link Terminal} instance, and
* makes sure that a list of lines can be updated and printed properly to
* the terminal in the most efficient order.
* <p>
* Example uses are for showing a list of {@link ProgressLine}'es, or
* handling the internals of a {@link Selection}.
*/
public class LineBuffer implements List<String>, RandomAccess, Closeable {
private final PrintStream writer;
private final ArrayList<String> buffer;
/**
* Create a LineBuffer instance.
*
* @param writer The terminal to wrap.
*/
public LineBuffer(PrintStream writer) {
this(writer, 16);
}
/**
* Create a LineBuffer instance.
*
* @param writer The terminal to wrap.
* @param initialCapacity Initial capacity of contained array list.
*/
public LineBuffer(PrintStream writer, int initialCapacity) {
this.writer = writer;
this.buffer = new ArrayList<>(initialCapacity);
}
// --- Core Functionality ---
/**
* @return Content of line buffer as a list.
*/
public List<String> asList() {
return List.copyOf(buffer);
}
/**
* Clear the last N lines, and move the cursor to the end of the last
* remaining line.
*
* @param N Number of lines to clear.
*/
public void clearLast(int N) {
if (N == 0) {
return;
}
if (N < 0) {
throw new IllegalArgumentException("Unable to clear " + N + " lines");
}
if (N > size()) {
throw new IllegalArgumentException("Count: " + N + ", Size: " + size());
}
if (N == size()) {
clear();
return;
}
writer.print(CR);
writer.print(CURSOR_ERASE);
buffer.remove(buffer.size() - 1);
for (int i = 1; i < N; ++i) {
writer.print(UP);
writer.print(CURSOR_ERASE);
buffer.remove(buffer.size() - 1);
}
writer.print(UP);
writer.print(cursorRight(printableWidth(lastLine())));
}
/**
* Set a number of lines starting at given offset.
*
* @param startOffset The start offset.
* @param lines The lines to set.
* @return If any lines were set.
*/
public boolean setAll(int startOffset, Collection<String> lines) {
if (startOffset == size()) {
return addAll(lines);
} else if (startOffset < 0 || startOffset > size()) {
throw new IndexOutOfBoundsException(
"Set offset=" + startOffset + " size=" + size());
} else if (lines.isEmpty()) {
return false;
}
var change = false;
var up = (size() - startOffset) - 1;
writer.print(CR);
if (up > 0) {
writer.print(cursorUp(up));
}
var list = List.copyOf(lines);
var line1 = list.get(0);
var old1 = buffer.set(startOffset, line1);
if (!line1.equals(old1)) {
writer.print(CURSOR_ERASE);
writer.print(line1);
change = true;
} else if (list.size() == 1 && startOffset == size() - 1) {
// Only replacing last line.
writer.print(cursorRight(printableWidth(line1)));
}
for (int i = 1; i < list.size(); ++i) {
writer.print(CR);
writer.print(LF);
var line = list.get(i);
var bufferPos = startOffset + i;
if (bufferPos >= buffer.size()) {
// Adding lines at end of buffer.
buffer.add(line);
writer.print(line);
change = true;
} else {
var old = buffer.set(bufferPos, line);
if (!line.equals(old)) {
// Replacing a line.
writer.print(CURSOR_ERASE);
writer.print(line);
change = true;
} else if (up < 1 && i == (list.size() - 1)) {
// Last line is not replaced, so we must move cursor to end
// of line.
writer.print(cursorRight(printableWidth(line)));
}
}
--up;
}
if (up > 0) {
writer.print(CR);
writer.print(cursorDown(up));
writer.print(cursorRight(printableWidth(lastLine())));
}
return change;
}
// --- Closeable
@Override
public void close() {
if (size() > 0 && !lastLine().isEmpty()) {
writer.print(CR);
writer.print(LF);
}
}
// --- List: Core Functionality ---
@Override
public void clear() {
if (!buffer.isEmpty()) {
writer.print(CR);
writer.print(CURSOR_ERASE);
for (int i = 1; i < buffer.size(); ++i) {
writer.print(UP);
writer.print(CURSOR_ERASE);
}
buffer.clear();
}
}
@Override
public int size() {
return buffer.size();
}
@Override
public boolean isEmpty() {
return buffer.isEmpty();
}
@Override
public String get(int i) {
return buffer.get(i);
}
@Override
public boolean add(String line) {
requireNonNull(line, "line == null");
buffer.add(line);
if (size() > 1) {
writer.print(CR);
writer.print(LF);
}
writer.print(line);
return true;
}
@Override
public void add(int i, String line) {
if (i < 0 || i > size()) {
throw new IndexOutOfBoundsException("Add index: " + i + " size: " + size());
}
if (i == size()) {
add(line);
return;
}
var up = (size() - i) - 1;
writer.print(CR);
writer.print(cursorUp(up));
buffer.add(i, line);
// print the inserted line
writer.print(CURSOR_ERASE);
writer.print(line);
// and shift everything after that 1 down.
for (int p = i + 1; p < size(); ++p) {
writer.print(CR);
writer.print(LF);
writer.print(CURSOR_ERASE);
writer.print(buffer.get(p));
}
}
@Override
public boolean addAll(int i, Collection<? extends String> collection) {
if (i < 0 || i > size()) {
throw new IndexOutOfBoundsException("AddAll index: " + i + " size: " + size());
}
if (collection.isEmpty()) {
return false;
}
if (i == size()) {
return addAll(collection);
}
var up = size() - i;
writer.print(CR);
writer.print(cursorUp(up));
buffer.addAll(i, collection);
for (int p = i; p < size(); ++p) {
writer.print(CURSOR_ERASE);
writer.print(buffer.get(p));
}
return true;
}
@Override
public String set(int i, String line) {
requireNonNull(line, "line == null");
if (i == size()) {
add(line);
return null;
}
var old = buffer.set(i, line);
var up = (size() - i) - 1;
writer.print(CR);
if (up > 0) {
writer.print(cursorUp(up));
}
writer.print(CURSOR_ERASE);
writer.print(line);
if (up > 0) {
writer.print(CR);
writer.print(cursorDown(up));
writer.print(cursorRight(printableWidth(lastLine())));
}
return old;
}
@Override
public String remove(int i) {
var old = buffer.remove(i);
// Remove last entry (we have 1 less anyway).
writer.print(CR);
writer.print(CURSOR_ERASE);
if (i == size()) {
// Removed last entry.
if (!isEmpty()) {
// It's not empty, move to end of last line.
writer.print(UP);
writer.print(cursorRight(printableWidth(lastLine())));
}
} else {
// Move everything else up 1 line.
var up = size() - i;
writer.print(cursorUp(up));
for (int p = i; p < size(); ++p) {
if (p > i) {
writer.print(CR);
writer.print(LF);
}
writer.print(CURSOR_ERASE);
writer.print(buffer.get(p));
}
}
return old;
}
// --- List: Compatibility ---
@Override
public boolean addAll(Collection<? extends String> collection) {
if (collection.isEmpty()) {
return false;
}
collection.forEach(this::add);
return true;
}
@Override
public boolean remove(Object o) {
var index = indexOf(o);
if (index >= 0) {
remove(index);
return true;
}
return false;
}
@Override
public boolean removeAll(Collection<?> collection) {
if (collection.isEmpty()) {
return false;
}
var origSize = size();
buffer.removeAll(collection);
// Just rewrite the whole line buffer.
return rewriteBuffer(origSize);
}
@Override
public boolean retainAll(Collection<?> collection) {
var origSize = size();
buffer.retainAll(collection);
// Just rewrite the whole line buffer.
return rewriteBuffer(origSize);
}
@Override
public List<String> subList(int start, int end) {
return List.copyOf(buffer.subList(start, end));
}
@Override
public int indexOf(Object o) {
return buffer.indexOf(o);
}
@Override
public int lastIndexOf(Object o) {
return buffer.lastIndexOf(o);
}
@Override
public boolean contains(Object o) {
return buffer.contains(o);
}
@Override
public boolean containsAll(Collection<?> collection) {
return buffer.containsAll(collection);
}
@Override
public Object[] toArray() {
return buffer.toArray();
}
@Override
public <T> T[] toArray(T[] ts) {
return buffer.toArray(ts);
}
@Override
public Iterator<String> iterator() {
return new LineIterator();
}
@Override
public ListIterator<String> listIterator() {
return new LineIterator();
}
@Override
public ListIterator<String> listIterator(int i) {
return new LineIterator(i);
}
// --- Object ---
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (!(obj instanceof LineBuffer)) {
return false;
}
var other = ((LineBuffer) obj);
return other.buffer.equals(buffer);
}
@Override
public int hashCode() {
return Objects.hash(buffer);
}
@Override
public String toString() {
if (buffer.isEmpty()) return "[]";
return buffer.stream()
.map(EscapeUtil::javaEscape)
.collect(joining("\", \"", "[\"", "\"]"));
}
// --- List: Java 21 Compat ---
/**
* Compatibility method for java-21.
*
* @return The list of entries, reversed.
*/
public List<String> reversed() {
return UnmodifiableList.asList(buffer).reversed();
}
// --- Private ---
private boolean rewriteBuffer(int origSize) {
if (origSize != size()) {
writer.print(CR);
writer.print(CURSOR_ERASE);
while (--origSize > 0) {
writer.print(UP);
writer.print(CURSOR_ERASE);
}
if (size() > 0) {
writer.print(buffer.get(0));
for (int i = 1; i < size(); ++i) {
writer.print(CR);
writer.print(LF);
writer.print(buffer.get(i));
}
}
return true;
}
return false;
}
/**
* List iterator over the line buffer entries.
*/
private class LineIterator implements ListIterator<String> {
private int previousIndex;
private int nextIndex;
private int lastIndex;
private LineIterator(int pos) {
lastIndex = -1;
previousIndex = pos - 1;
nextIndex = pos;
}
private LineIterator() {
previousIndex = lastIndex = -1;
nextIndex = 0;
}
@Override
public boolean hasNext() {
return nextIndex < size();
}
@Override
public boolean hasPrevious() {
return previousIndex >= 0;
}
@Override
public int nextIndex() {
if (nextIndex >= size()) {
return -1;
}
return nextIndex;
}
@Override
public int previousIndex() {
return previousIndex;
}
@Override
public String next() {
var out = get(nextIndex);
lastIndex = nextIndex;
++nextIndex;
++previousIndex;
return out;
}
@Override
public String previous() {
var out = get(previousIndex);
lastIndex = previousIndex;
--previousIndex;
--nextIndex;
return out;
}
@Override
public void remove() {
if (lastIndex < 0) throw new IndexOutOfBoundsException("Remove " + lastIndex);
LineBuffer.this.remove(lastIndex);
if (lastIndex == previousIndex) {
--previousIndex;
--nextIndex;
}
lastIndex = -1;
}
@Override
public void set(String line) {
if (lastIndex < 0) {
throw new IndexOutOfBoundsException("Set " + lastIndex);
}
LineBuffer.this.set(lastIndex, line);
}
@Override
public void add(String line) {
LineBuffer.this.add(nextIndex, line);
lastIndex = -1;
++nextIndex;
++previousIndex;
}
}
/**
* @return The content of the last line in the buffer.
*/
private String lastLine() {
return buffer.get(buffer.size() - 1);
}
}