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 java.io.PrintStream; 022import java.util.ArrayList; 023import java.util.LinkedHashMap; 024import java.util.List; 025import java.util.Map; 026import java.util.Objects; 027import java.util.stream.IntStream; 028import org.jdrupes.builder.core.console.Ansi.Color; 029 030/// Provides a split console using ANSI escape sequences. 031/// 032public class SplitConsole { 033 034 private final TerminalInfo term; 035 private final List<ManagedLine> managedLines = new ArrayList<>(); 036 @SuppressWarnings("PMD.UseConcurrentHashMap") 037 private final Map<Thread, String> offScreenLines = new LinkedHashMap<>(); 038 private final PrintStream out = System.out; 039 040 /// The Class ManagedLine. 041 /// 042 @SuppressWarnings({ "PMD.ClassWithOnlyPrivateConstructorsShouldBeFinal" }) 043 private class ManagedLine { 044 private Thread thread; 045 private String text = ""; 046 private String lastRendered; 047 private Color color = Color.BLUE; 048 } 049 050 /// Initializes a new split console. 051 /// 052 public SplitConsole() { 053 this.term = new TerminalInfo(); 054 055 if (term.supportsAnsi()) { 056 for (int i = 0; i < term.lines() * 1 / 3; i++) { 057 out.println(); 058 } 059 recomputeLayout(term.lines() * 1 / 3); 060 redraw(); 061 } 062 } 063 064 private ManagedLine managedLine(Thread thread) { 065 return managedLines.stream() 066 .filter(l -> Objects.equals(l.thread, thread)) 067 .findFirst().orElse(null); 068 } 069 070 /// Allocate a line for outputs from the current thread. 071 /// 072 @SuppressWarnings({ "PMD.AvoidSynchronizedAtMethodLevel", 073 "PMD.AvoidDuplicateLiterals" }) 074 private synchronized void allocateLine() { 075 Thread thread = Thread.currentThread(); 076 if (managedLine(thread) != null || offScreenLines.containsKey(thread)) { 077 return; 078 } 079 080 // Find free and allocate or use overflow 081 IntStream.range(0, managedLines.size()) 082 .filter(i -> managedLines.get(i).thread == null).findFirst() 083 .ifPresentOrElse(i -> { 084 initManaged(i, thread, ""); 085 }, () -> { 086 offScreenLines.put(thread, ""); 087 }); 088 } 089 090 private void initManaged(int index, Thread thread, String text) { 091 var line = managedLines.get(index); 092 line.thread = thread; 093 line.text = text; 094 line.color = pickColor(thread); 095 } 096 097 /// Deallocate the line for outputs from the current thread. 098 /// 099 @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") 100 private synchronized void deallocateLine() { 101 Thread thread = Thread.currentThread(); 102 var line = managedLine(thread); 103 104 if (line != null) { 105 line.thread = null; 106 line.text = ""; 107 promoteOffScreenLines(); 108 redraw(); 109 } else { 110 offScreenLines.remove(thread); 111 } 112 } 113 114 @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") 115 private synchronized void recomputeLayout(int managedHeight) { 116 while (managedLines.size() > managedHeight) { 117 if (managedLines.get(managedLines.size() - 1).thread == null) { 118 managedLines.remove(managedLines.size() - 1); 119 break; 120 } 121 int freeSlot; 122 for (freeSlot = 0; freeSlot < managedLines.size() - 1; freeSlot++) { 123 if (managedLines.get(freeSlot).thread == null) { 124 break; 125 } 126 } 127 if (freeSlot < managedLines.size() - 1) { 128 ManagedLine last = managedLines.get(managedLines.size() - 1); 129 managedLines.set(freeSlot, last); 130 continue; 131 } 132 ManagedLine last = managedLines.get(managedLines.size() - 1); 133 offScreenLines.put(last.thread, last.text); 134 last.thread = null; 135 } 136 137 if (managedLines.size() < managedHeight) { 138 while (managedLines.size() < managedHeight) { 139 managedLines.add(new ManagedLine()); 140 } 141 promoteOffScreenLines(); 142 } 143 } 144 145 private void promoteOffScreenLines() { 146 for (int i = 0; i < managedLines.size(); i++) { 147 if (offScreenLines.isEmpty()) { 148 break; 149 } 150 if (managedLines.get(i).thread == null) { 151 shiftManaged(i); 152 var offLine = offScreenLines.entrySet().iterator().next(); 153 initManaged(managedLines.size() - 1, offLine.getKey(), 154 offLine.getValue()); 155 offScreenLines.remove(offLine.getKey()); 156 } 157 } 158 } 159 160 private void shiftManaged(int index) { 161 var lastManaged = managedLines.size() - 1; 162 for (int i = index; i < lastManaged; i++) { 163 var toMove = managedLines.get(i + 1); 164 managedLines.set(i, toMove); 165 toMove.lastRendered = null; 166 } 167 managedLines.set(lastManaged, new ManagedLine()); 168 } 169 170 /// Update the line for outputs from the current thread. 171 /// 172 /// @param text the text 173 /// 174 @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") 175 private synchronized void writeLine(String text) { 176 Thread thread = Thread.currentThread(); 177 var line = managedLine(thread); 178 179// String spinnerChar = ""; 180// Spinner spinner = threadSpinners.get(thread); 181// if (spinner != null) { 182// spinnerChar = spinner.next(); 183// } 184// 185// String coloredText = applyColor(thread, spinnerChar + " " + text); 186 187 if (line != null) { 188 line.text = text; 189 redraw(); 190 } else if (offScreenLines.containsKey(thread)) { 191 offScreenLines.put(thread, text); 192 } 193 } 194 195// private String applyColor(Thread thread, String text) { 196// Integer color = threadColors.get(thread); 197// if (color != null) { 198// return Ansi.color(color) + text + Ansi.RESET; 199// } 200// return text; 201// } 202 203 private Color pickColor(Thread thread) { 204 Color[] codes = Color.values(); 205 return codes[Math.abs(thread.hashCode()) % codes.length]; 206 } 207 208 /// Prints a line in the scrollable part of the console. 209 /// 210 /// @param text the text 211 /// 212 public void println(String text) { 213 int lastScrolled = term.lines() - managedLines.size() - 1; 214 out.println(Ansi.hideCursor() + Ansi.scrollRange(0, lastScrolled) 215 + Ansi.scrollUp() + Ansi.scrollAll() 216 + Ansi.cursorAt(lastScrolled - 1, 0) 217 + Ansi.showCursor() + text); 218 out.flush(); 219 } 220 221 @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") 222 private synchronized void redraw() { 223 redraw(false); 224 } 225 226 @SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel") 227 private synchronized void redraw(boolean force) { 228 if (!term.supportsAnsi()) { 229 return; 230 } 231 232 var managedStart = term.lines() - managedLines.size(); 233 out.print(Ansi.hideCursor()); 234 for (int i = 0; i < managedLines.size(); i++) { 235 ManagedLine line = managedLines.get(i); 236 var row = managedStart + i; 237 if (!Objects.equals(line.text, line.lastRendered) || force) { 238 out.print(Ansi.cursorAt(row, 0) + Ansi.clearLine() 239 + line.text); 240 line.lastRendered = line.text; 241 } 242 } 243 244 out.print(Ansi.cursorAt(managedStart - 1, 0) 245 + Ansi.showCursor()); 246 out.flush(); 247 } 248 249 /// Close. 250 /// 251 public void close() { 252 if (term.supportsAnsi()) { 253 out.print(Ansi.showCursor()); 254 } 255 } 256 257 /// Allocates a line for outputs from the current thread. 258 /// 259 /// @return the status line 260 /// 261 public StatusLine statusLine() { 262 return new StatusLine(); 263 } 264 265 /// Represents a status line for outputs from the current thread. 266 /// 267 public final class StatusLine implements AutoCloseable { 268 269 /// Initializes a new status line for the invoking thread. 270 /// 271 private StatusLine() { 272 allocateLine(); 273 } 274 275 /// Update the text in the status line. 276 /// 277 /// @param text the text 278 /// 279 public void update(String text) { 280 writeLine(text); 281 } 282 283 /// Deallocate the line for outputs from the current thread. 284 /// 285 @Override 286 public void close() { 287 deallocateLine(); 288 ; 289 } 290 } 291 292}