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);
    }
}