001/*
002 * JDrupes Builder
003 * Copyright (C) 2026 Michael N. Lipp
004 * 
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
017 */
018
019package org.jdrupes.builder.core.console;
020
021import java.io.PrintStream;
022import java.util.ArrayList;
023import java.util.LinkedHashMap;
024import java.util.List;
025import java.util.Map;
026import java.util.Objects;
027import java.util.stream.IntStream;
028import org.jdrupes.builder.core.console.Ansi.Color;
029
030/// Provides a split console using ANSI escape sequences.
031///
032public class SplitConsole {
033
034    private final TerminalInfo term;
035    private final List<ManagedLine> managedLines = new ArrayList<>();
036    @SuppressWarnings("PMD.UseConcurrentHashMap")
037    private final Map<Thread, String> offScreenLines = new LinkedHashMap<>();
038    private final PrintStream out = System.out;
039
040    /// The Class ManagedLine.
041    ///
042    @SuppressWarnings({ "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" })
043    private class ManagedLine {
044        private Thread thread;
045        private String text = "";
046        private String lastRendered;
047        private Color color = Color.BLUE;
048    }
049
050    /// Initializes a new split console.
051    ///
052    public SplitConsole() {
053        this.term = new TerminalInfo();
054
055        if (term.supportsAnsi()) {
056            for (int i = 0; i < term.lines() * 1 / 3; i++) {
057                out.println();
058            }
059            recomputeLayout(term.lines() * 1 / 3);
060            redraw();
061        }
062    }
063
064    private ManagedLine managedLine(Thread thread) {
065        return managedLines.stream()
066            .filter(l -> Objects.equals(l.thread, thread))
067            .findFirst().orElse(null);
068    }
069
070    /// Allocate a line for outputs from the current thread. 
071    ///
072    @SuppressWarnings({ "PMD.AvoidSynchronizedAtMethodLevel",
073        "PMD.AvoidDuplicateLiterals" })
074    private synchronized void allocateLine() {
075        Thread thread = Thread.currentThread();
076        if (managedLine(thread) != null || offScreenLines.containsKey(thread)) {
077            return;
078        }
079
080        // Find free and allocate or use overflow
081        IntStream.range(0, managedLines.size())
082            .filter(i -> managedLines.get(i).thread == null).findFirst()
083            .ifPresentOrElse(i -> {
084                initManaged(i, thread, "");
085            }, () -> {
086                offScreenLines.put(thread, "");
087            });
088    }
089
090    private void initManaged(int index, Thread thread, String text) {
091        var line = managedLines.get(index);
092        line.thread = thread;
093        line.text = text;
094        line.color = pickColor(thread);
095    }
096
097    /// Deallocate the line for outputs from the current thread.
098    ///
099    @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
100    private synchronized void deallocateLine() {
101        Thread thread = Thread.currentThread();
102        var line = managedLine(thread);
103
104        if (line != null) {
105            line.thread = null;
106            line.text = "";
107            promoteOffScreenLines();
108            redraw();
109        } else {
110            offScreenLines.remove(thread);
111        }
112    }
113
114    @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
115    private synchronized void recomputeLayout(int managedHeight) {
116        while (managedLines.size() > managedHeight) {
117            if (managedLines.get(managedLines.size() - 1).thread == null) {
118                managedLines.remove(managedLines.size() - 1);
119                break;
120            }
121            int freeSlot;
122            for (freeSlot = 0; freeSlot < managedLines.size() - 1; freeSlot++) {
123                if (managedLines.get(freeSlot).thread == null) {
124                    break;
125                }
126            }
127            if (freeSlot < managedLines.size() - 1) {
128                ManagedLine last = managedLines.get(managedLines.size() - 1);
129                managedLines.set(freeSlot, last);
130                continue;
131            }
132            ManagedLine last = managedLines.get(managedLines.size() - 1);
133            offScreenLines.put(last.thread, last.text);
134            last.thread = null;
135        }
136
137        if (managedLines.size() < managedHeight) {
138            while (managedLines.size() < managedHeight) {
139                managedLines.add(new ManagedLine());
140            }
141            promoteOffScreenLines();
142        }
143    }
144
145    private void promoteOffScreenLines() {
146        for (int i = 0; i < managedLines.size(); i++) {
147            if (offScreenLines.isEmpty()) {
148                break;
149            }
150            if (managedLines.get(i).thread == null) {
151                shiftManaged(i);
152                var offLine = offScreenLines.entrySet().iterator().next();
153                initManaged(managedLines.size() - 1, offLine.getKey(),
154                    offLine.getValue());
155                offScreenLines.remove(offLine.getKey());
156            }
157        }
158    }
159
160    private void shiftManaged(int index) {
161        var lastManaged = managedLines.size() - 1;
162        for (int i = index; i < lastManaged; i++) {
163            var toMove = managedLines.get(i + 1);
164            managedLines.set(i, toMove);
165            toMove.lastRendered = null;
166        }
167        managedLines.set(lastManaged, new ManagedLine());
168    }
169
170    /// Update the line for outputs from the current thread.
171    ///
172    /// @param text the text
173    ///
174    @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
175    private synchronized void writeLine(String text) {
176        Thread thread = Thread.currentThread();
177        var line = managedLine(thread);
178
179//        String spinnerChar = "";
180//        Spinner spinner = threadSpinners.get(thread);
181//        if (spinner != null) {
182//            spinnerChar = spinner.next();
183//        }
184//
185//        String coloredText = applyColor(thread, spinnerChar + " " + text);
186
187        if (line != null) {
188            line.text = text;
189            redraw();
190        } else if (offScreenLines.containsKey(thread)) {
191            offScreenLines.put(thread, text);
192        }
193    }
194
195//    private String applyColor(Thread thread, String text) {
196//        Integer color = threadColors.get(thread);
197//        if (color != null) {
198//            return Ansi.color(color) + text + Ansi.RESET;
199//        }
200//        return text;
201//    }
202
203    private Color pickColor(Thread thread) {
204        Color[] codes = Color.values();
205        return codes[Math.abs(thread.hashCode()) % codes.length];
206    }
207
208    /// Prints a line in the scrollable part of the console.
209    ///
210    /// @param text the text
211    ///
212    public void println(String text) {
213        int lastScrolled = term.lines() - managedLines.size() - 1;
214        out.println(Ansi.hideCursor() + Ansi.scrollRange(0, lastScrolled)
215            + Ansi.scrollUp() + Ansi.scrollAll()
216            + Ansi.cursorAt(lastScrolled - 1, 0)
217            + Ansi.showCursor() + text);
218        out.flush();
219    }
220
221    @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
222    private synchronized void redraw() {
223        redraw(false);
224    }
225
226    @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
227    private synchronized void redraw(boolean force) {
228        if (!term.supportsAnsi()) {
229            return;
230        }
231
232        var managedStart = term.lines() - managedLines.size();
233        out.print(Ansi.hideCursor());
234        for (int i = 0; i < managedLines.size(); i++) {
235            ManagedLine line = managedLines.get(i);
236            var row = managedStart + i;
237            if (!Objects.equals(line.text, line.lastRendered) || force) {
238                out.print(Ansi.cursorAt(row, 0) + Ansi.clearLine()
239                    + line.text);
240                line.lastRendered = line.text;
241            }
242        }
243
244        out.print(Ansi.cursorAt(managedStart - 1, 0)
245            + Ansi.showCursor());
246        out.flush();
247    }
248
249    /// Close.
250    ///
251    public void close() {
252        if (term.supportsAnsi()) {
253            out.print(Ansi.showCursor());
254        }
255    }
256
257    /// Allocates a line for outputs from the current thread.
258    ///
259    /// @return the status line
260    ///
261    public StatusLine statusLine() {
262        return new StatusLine();
263    }
264
265    /// Represents a status line for outputs from the current thread.
266    ///
267    public final class StatusLine implements AutoCloseable {
268
269        /// Initializes a new status line for the invoking thread.
270        ///
271        private StatusLine() {
272            allocateLine();
273        }
274
275        /// Update the text in the status line.
276        ///
277        /// @param text the text
278        ///
279        public void update(String text) {
280            writeLine(text);
281        }
282
283        /// Deallocate the line for outputs from the current thread.
284        ///
285        @Override
286        public void close() {
287            deallocateLine();
288            ;
289        }
290    }
291
292}