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