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}