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 com.google.common.flogger.FluentLogger;
022import java.io.IOException;
023import java.io.OutputStream;
024import java.io.PrintStream;
025import java.io.PrintWriter;
026import java.io.Writer;
027import java.nio.charset.Charset;
028import java.util.ArrayList;
029import java.util.LinkedHashMap;
030import java.util.List;
031import java.util.Map;
032import java.util.Objects;
033import java.util.Optional;
034import java.util.concurrent.atomic.AtomicBoolean;
035import java.util.concurrent.atomic.AtomicInteger;
036import java.util.stream.IntStream;
037import org.jdrupes.builder.api.StatusLine;
038
039/// Provides a split console using ANSI escape sequences.
040///
041@SuppressWarnings({ "PMD.AvoidSynchronizedStatement", "PMD.GodClass" })
042public final class SplitConsole implements AutoCloseable {
043
044    private static final FluentLogger logger = FluentLogger.forEnclosingClass();
045    private static final StatusLine NULL_STATUS_LINE = new StatusLine() {
046        @Override
047        public void update(String text, Object... args) {
048            // Does nothing
049        }
050
051        @Override
052        @SuppressWarnings("PMD.RelianceOnDefaultCharset")
053        public PrintWriter writer(String prefix) {
054            return new PrintWriter(OutputStream.nullOutputStream());
055        }
056
057        @Override
058        public void close() {
059            // Does nothing
060        }
061    };
062    private static AtomicInteger openCount = new AtomicInteger();
063    private static SplitConsole instance;
064    private final TerminalInfo term;
065    private final List<ManagedLine> managedLines = new ArrayList<>();
066    @SuppressWarnings("PMD.UseConcurrentHashMap")
067    // Guarded by managedLines
068    private final Map<Thread, String> offScreenLines = new LinkedHashMap<>();
069    // Used to synchronize output to terminal
070    private final PrintStream realOut;
071    private final PrintStream splitOut;
072    private final PrintStream splitErr;
073    private byte[] incompleteLine = new byte[0];
074    private final Redrawer redrawer;
075
076    /// The Class ManagedLine.
077    ///
078    @SuppressWarnings({ "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" })
079    private class ManagedLine {
080        private Thread thread;
081        private String text = "";
082        private String lastRendered;
083    }
084
085    /// Open the split console.
086    ///
087    /// @return the split console
088    ///
089    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" })
090    public static SplitConsole open() {
091        synchronized (openCount) {
092            if (openCount.incrementAndGet() == 1) {
093                instance = new SplitConsole();
094            }
095            return instance;
096        }
097    }
098
099    /// Return a status line implementation that discards all update
100    /// information.
101    ///
102    /// @return the status line
103    ///
104    public static StatusLine nullStatusLine() {
105        return NULL_STATUS_LINE;
106    }
107
108    /// Initializes a new split console.
109    ///
110    @SuppressWarnings({ "PMD.ForLoopCanBeForeach" })
111    private SplitConsole() {
112        logger.atFine().log("Initializing split console");
113        realOut = System.out;
114
115        this.term = new TerminalInfo();
116        if (!term.supportsAnsi()) {
117            logger.atFine().log("No ANSI terminal support, using plain output");
118            redrawer = null;
119            splitOut = realOut;
120            splitErr = realOut;
121            return;
122        }
123
124        logger.atFine().log("Using ANSI terminal support for split console");
125        synchronized (managedLines) {
126            recomputeLayout(term.lines() * 1 / 3);
127            synchronized (realOut) {
128                for (int i = 0; i < managedLines.size(); i++) {
129                    realOut.println();
130                }
131                realOut.print(Ansi.cursorUp(managedLines.size()));
132                realOut.flush();
133            }
134        }
135        redrawer = new Redrawer();
136        redrawStatus();
137        splitOut = new PrintStream(new StreamWrapper(null), true,
138            Charset.defaultCharset());
139        splitErr = new PrintStream(new StreamWrapper(Ansi.Color.Red), true,
140            Charset.defaultCharset());
141        System.setOut(splitOut);
142        System.setErr(splitErr);
143    }
144
145    private Optional<ManagedLine> managedLine(Thread thread) {
146        Objects.nonNull(thread);
147        return managedLines.stream()
148            .filter(l -> Objects.equals(l.thread, thread))
149            .findFirst();
150    }
151
152    /// Allocate a line for outputs from the current thread. 
153    ///
154    private void allocateLine() {
155        if (openCount.get() == 0) {
156            return;
157        }
158        Thread thread = Thread.currentThread();
159        synchronized (managedLines) {
160            if (managedLine(thread).isPresent()
161                || offScreenLines.containsKey(thread)) {
162                return;
163            }
164
165            // Find free and allocate or use overflow
166            IntStream.range(0, managedLines.size())
167                .filter(i -> managedLines.get(i).thread == null).findFirst()
168                .ifPresentOrElse(i -> {
169                    initManaged(i, thread, "");
170                }, () -> {
171                    offScreenLines.put(thread, "");
172                });
173        }
174    }
175
176    private void initManaged(int index, Thread thread, String text) {
177        var line = managedLines.get(index);
178        line.thread = thread;
179        line.text = text;
180        line.lastRendered = null;
181    }
182
183    /// Deallocate the line for outputs from the current thread.
184    ///
185    private void deallocateLine() {
186        Thread thread = Thread.currentThread();
187        synchronized (managedLines) {
188            managedLine(thread).ifPresentOrElse(line -> {
189                line.thread = null;
190                line.text = "";
191                promoteOffScreenLines();
192                redrawStatus();
193            }, () -> offScreenLines.remove(thread));
194        }
195    }
196
197    private void recomputeLayout(int managedHeight) {
198        while (managedLines.size() > managedHeight) {
199            if (managedLines.get(managedLines.size() - 1).thread == null) {
200                managedLines.remove(managedLines.size() - 1);
201                break;
202            }
203            int freeSlot;
204            for (freeSlot = 0; freeSlot < managedLines.size() - 1;
205                    freeSlot++) {
206                if (managedLines.get(freeSlot).thread == null) {
207                    break;
208                }
209            }
210            if (freeSlot < managedLines.size() - 1) {
211                ManagedLine last = managedLines.get(managedLines.size() - 1);
212                managedLines.set(freeSlot, last);
213                continue;
214            }
215            ManagedLine last = managedLines.get(managedLines.size() - 1);
216            offScreenLines.put(last.thread, last.text);
217            last.thread = null;
218        }
219
220        if (managedLines.size() < managedHeight) {
221            while (managedLines.size() < managedHeight) {
222                managedLines.add(new ManagedLine());
223            }
224            promoteOffScreenLines();
225        }
226    }
227
228    private void promoteOffScreenLines() {
229        for (int i = 0; i < managedLines.size(); i++) {
230            if (offScreenLines.isEmpty()) {
231                break;
232            }
233            if (managedLines.get(i).thread == null) {
234                // shiftManaged(i);
235                var offLineIter = offScreenLines.entrySet().iterator();
236                var offLine = offLineIter.next();
237                initManaged(i, offLine.getKey(), offLine.getValue());
238                offLineIter.remove();
239            }
240        }
241    }
242
243    /// Update the line for outputs from the current thread.
244    ///
245    /// @param text the text
246    ///
247    private void updateStatus(String text, Object... args) {
248        Thread thread = Thread.currentThread();
249        synchronized (managedLines) {
250            managedLine(thread).ifPresentOrElse(line -> {
251                if (args.length > 0) {
252                    line.text = String.format(text, args);
253                } else {
254                    line.text = text;
255                }
256                logger.atFinest().log(
257                    "Updated status for %s: %s", line.thread, line.text);
258                redrawStatus();
259            }, () -> {
260                offScreenLines.put(thread, text);
261            });
262        }
263    }
264
265    /// Writes the bytes to the scrollable part of the console.
266    ///
267    /// @param text the text
268    ///
269    @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition" })
270    private void write(byte[] text, int offset, int length, byte[] markup) {
271        synchronized (realOut) {
272            if (incompleteLine.length > 0) {
273                // Prepend left over text and try again
274                byte[] prepended = new byte[incompleteLine.length + length];
275                System.arraycopy(
276                    incompleteLine, 0, prepended, 0, incompleteLine.length);
277                System.arraycopy(
278                    text, offset, prepended, incompleteLine.length, length);
279                incompleteLine = new byte[0];
280                write(prepended, markup);
281                return;
282            }
283
284            // Write line(s)
285            realOut.print(Ansi.cursorToSol() + Ansi.clearLine());
286            int end = offset + length;
287            for (int i = offset; i < end; i++) {
288                if (text[i] == '\n') {
289                    // Write line including newline, moves cursor to next line
290                    writeMarkedup(realOut, text, offset, i - offset + 1,
291                        markup);
292                    offset = i + 1;
293                    // Clear left over text from status line
294                    realOut.print(Ansi.clearLine());
295                }
296                if (i - offset >= term.columns()) {
297                    // Write line up to here
298                    writeMarkedup(realOut, text, offset, i - offset, markup);
299                    offset = i;
300                    // Write newline an clear left over text from status line
301                    realOut.print("\n" + Ansi.clearLine());
302                }
303            }
304            incompleteLine = new byte[end - offset];
305            System.arraycopy(text, offset, incompleteLine, 0, end - offset);
306            if (incompleteLine.length > 0) {
307                // Show incomplete line, will be overwritten by next write
308                realOut.write(incompleteLine, 0, incompleteLine.length);
309            }
310            realOut.flush();
311        }
312        redrawStatus(true);
313    }
314
315    private void write(byte[] text, byte[] markup) {
316        write(text, 0, text.length, markup);
317    }
318
319    @SuppressWarnings("PMD.RelianceOnDefaultCharset")
320    private void writeMarkedup(PrintStream out, byte[] chars, int off, int len,
321            byte[] markup) {
322        // Only called by write, already synchronized
323        if (markup == null) {
324            out.write(chars, off, len);
325            return;
326        }
327        out.write(markup, 0, markup.length);
328        out.write(chars, off, len);
329        out.write(Ansi.resetAttributes().getBytes(), 0,
330            Ansi.resetAttributes().length());
331    }
332
333    private void redrawStatus() {
334        redrawStatus(false);
335    }
336
337    private void redrawStatus(boolean force) {
338        if (!term.supportsAnsi()) {
339            return;
340        }
341        if (redrawer != null) {
342            redrawer.triggerRedraw(force);
343        }
344    }
345
346    /// Redraws the status lines.
347    /// 
348    private final class Redrawer implements Runnable {
349        private final AtomicBoolean running = new AtomicBoolean(true);
350        private final AtomicBoolean redraw = new AtomicBoolean(false);
351        private final AtomicBoolean force = new AtomicBoolean(false);
352        private final Thread thread;
353
354        private Redrawer() {
355            thread = Thread.ofVirtual().name("Split console redrawer")
356                .start(this);
357        }
358
359        @Override
360        public void run() {
361            while (true) {
362                synchronized (this) {
363                    if (!running.get()) {
364                        break;
365                    }
366                    if (!redraw.get()) {
367                        try {
368                            wait();
369                        } catch (InterruptedException e) {
370                            break;
371                        }
372                    }
373                    redraw.set(false);
374                }
375                if (openCount.get() > 0) {
376                    doRedraw(force.getAndSet(false));
377                }
378            }
379        }
380
381        private void triggerRedraw(boolean force) {
382            synchronized (this) {
383                redraw.set(true);
384                if (force) {
385                    this.force.set(true);
386                }
387                notifyAll();
388            }
389        }
390
391        @SuppressWarnings({ "PMD.EmptyCatchBlock" })
392        private void stop() {
393            synchronized (this) {
394                logger.atFine().log("Stopping redrawer");
395                running.set(false);
396                notifyAll();
397            }
398            try {
399                thread.join();
400                logger.atFine().log("Redrawer stopped");
401            } catch (InterruptedException e) {
402                // Ignore
403            }
404        }
405    }
406
407    @SuppressWarnings({ "PMD.ForLoopCanBeForeach" })
408    private void doRedraw(boolean force) {
409        synchronized (managedLines) {
410            synchronized (realOut) {
411                realOut.print(Ansi.hideCursor());
412                for (int i = 0; i < managedLines.size(); i++) {
413                    realOut.println();
414                    ManagedLine line = managedLines.get(i);
415                    if (!force
416                        && Objects.equals(line.text, line.lastRendered)) {
417                        continue;
418                    }
419                    realOut.print(Ansi.cursorToSol() + Ansi.clearLine());
420                    if (openCount.get() > 0) {
421                        realOut.print("> " + line.text.substring(0,
422                            Math.min(line.text.length(), term.columns() - 2)));
423                    }
424                    line.lastRendered = line.text;
425                }
426                realOut.print(Ansi.cursorUp(managedLines.size())
427                    + Ansi.cursorToSol() + Ansi.showCursor());
428                if (incompleteLine.length > 0) {
429                    realOut.write(incompleteLine, 0, incompleteLine.length);
430                }
431                realOut.flush();
432            }
433        }
434    }
435
436    /// Writes the bytes to the scrollable part of the console.
437    ///
438    /// @return the prints the stream
439    ///
440    public PrintStream out() {
441        return splitOut;
442    }
443
444    /// Writes the bytes to the scrollable part of the console.
445    ///
446    /// @return the prints the stream
447    ///
448    public PrintStream err() {
449        return splitErr;
450    }
451
452    /// Close.
453    ///
454    @Override
455    public void close() {
456        synchronized (openCount) {
457            if (openCount.get() == 0) {
458                return;
459            }
460            if (openCount.decrementAndGet() > 0) {
461                return;
462            }
463
464            // Don't use this anymore
465            instance = null;
466
467            // Cleanup
468            logger.atFine().log("Closing split console");
469            if (redrawer != null) {
470                redrawer.stop();
471                synchronized (managedLines) {
472                    for (var line : managedLines) {
473                        line.thread = null;
474                        line.text = "";
475                        line.lastRendered = null;
476                    }
477                    offScreenLines.clear();
478                }
479                doRedraw(true);
480            }
481            System.setOut(realOut);
482            logger.atFine().log("Split console closed");
483        }
484    }
485
486    /// Allocates a line for outputs from the current thread.
487    ///
488    /// @return the status line
489    ///
490    public DefaultStatusLine statusLine() {
491        return new DefaultStatusLine();
492    }
493
494    /// Represents a status line for outputs from the current thread.
495    ///
496    public final class DefaultStatusLine implements StatusLine {
497
498        private PrintWriter asWriter;
499
500        /// Initializes a new status line for the invoking thread.
501        ///
502        private DefaultStatusLine() {
503            allocateLine();
504        }
505
506        /// Update.
507        ///
508        /// @param text the text
509        /// @param args the args
510        ///
511        @Override
512        public void update(String text, Object... args) {
513            updateStatus(text, args);
514        }
515
516        /// Writer.
517        ///
518        /// @param prefix the prefix
519        /// @return the prints the writer
520        ///
521        @Override
522        public PrintWriter writer(String prefix) {
523            if (asWriter == null) {
524                asWriter = new PrintWriter(new Writer() {
525                    private final String prepend = prefix == null ? "" : prefix;
526                    @SuppressWarnings("PMD.AvoidStringBufferField")
527                    private final StringBuilder buf = new StringBuilder();
528
529                    @Override
530                    public void write(char[] cbuf, int off, int len)
531                            throws IOException {
532                        for (int i = off; i < off + len; i++) {
533                            if (cbuf[i] == '\n' || cbuf[i] == '\r') {
534                                updateStatus(prepend + buf.toString());
535                                buf.setLength(0);
536                            } else {
537                                buf.append(cbuf[i]);
538                            }
539                        }
540                    }
541
542                    @Override
543                    public void flush() throws IOException {
544                        updateStatus(prepend + buf.toString());
545                    }
546
547                    @Override
548                    public void close() throws IOException {
549                        // Does nothing
550                    }
551
552                });
553            }
554            return asWriter;
555        }
556
557        /// Deallocate the line for outputs from the current thread.
558        ///
559        @Override
560        public void close() {
561            deallocateLine();
562        }
563    }
564
565    /// Makes [#write] available as a stream.
566    ///
567    public final class StreamWrapper extends OutputStream {
568
569        private final byte[] markup;
570
571        private StreamWrapper(Ansi.Color color) {
572            markup = Optional.ofNullable(color).map(Ansi::color)
573                .map(String::getBytes).orElse(null);
574        }
575
576        /// Write.
577        ///
578        /// @param ch the ch
579        /// @throws IOException Signals that an I/O exception has occurred.
580        ///
581        @Override
582        @SuppressWarnings("PMD.ShortVariable")
583        public void write(int ch) throws IOException {
584            SplitConsole.this.write(new byte[] { (byte) ch }, 0, 1, markup);
585        }
586
587        /// Write.
588        ///
589        /// @param ch the ch
590        /// @param off the off
591        /// @param len the len
592        /// @throws IOException Signals that an I/O exception has occurred.
593        ///
594        @Override
595        @SuppressWarnings("PMD.ShortVariable")
596        public void write(byte[] ch, int off, int len) throws IOException {
597            SplitConsole.this.write(ch, off, len, markup);
598        }
599
600        @Override
601        public void close() throws IOException {
602            // Don't forward close
603        }
604    }
605
606}