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}