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