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}