feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:18:18 +02:00
parent 44fde78c64
commit 64cc654803
2 changed files with 177 additions and 20 deletions

View File

@@ -1,43 +1,57 @@
package thb.jeanluc.adventure.io;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.Span;
import thb.jeanluc.adventure.io.text.Style;
import thb.jeanluc.adventure.io.text.StyledText;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import thb.jeanluc.adventure.io.text.StyledText;
import java.util.List;
/**
* {@link GameIO} backed by {@code System.in} and {@code System.out}.
* Each {@code write} appends a newline so output blocks are visually
* separated. {@code readLine} prints a {@code &gt; } prompt before
* blocking.
* {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
* (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
* and degrade safely (no colours when output is piped; ASCII frames on request).
*/
public class ConsoleIO implements GameIO {
/** Input source. */
/** Whether ANSI colour is emitted. */
public enum ColorMode { ON, OFF, AUTO }
/** Frame/glyph fidelity tier. */
public enum GlyphMode { ASCII, UNICODE, GLYPH }
private static final int FRAME_WIDTH = 44;
private final BufferedReader in;
/** Output sink. */
private final PrintStream out;
private final boolean useColor;
private final GlyphMode glyphs;
/**
* Creates a console IO using standard input and output.
*/
public ConsoleIO() {
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out);
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
}
/**
* Test-friendly constructor allowing custom streams.
*
* @param in reader to consume player input
* @param out stream to write player output
*/
/** Backward-compatible stream constructor (colour off, unicode frames). */
public ConsoleIO(BufferedReader in, PrintStream out) {
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
}
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
this.in = in;
this.out = out;
this.glyphs = glyphs;
this.useColor = switch (colorMode) {
case ON -> true;
case OFF -> false;
case AUTO -> System.console() != null;
};
}
@Override
@@ -54,6 +68,85 @@ public class ConsoleIO implements GameIO {
@Override
public void print(StyledText text) {
out.println(text.plainText());
StringBuilder sb = new StringBuilder();
for (Span s : text.spans()) {
sb.append(paint(s.style(), s.text()));
}
out.println(sb);
}
@Override
public void showRoom(RoomView room) {
out.println(paint(Style.HEADING, frameTop(room.name())));
out.println(room.description().stripTrailing());
if (!room.items().isEmpty()) {
out.println(section("Items", room.items(), Style.ITEM));
}
if (!room.npcs().isEmpty()) {
out.println(section("People", room.npcs(), Style.NPC));
}
if (!room.exits().isEmpty()) {
out.println(section("Exits", room.exits(), Style.EXIT));
} else {
out.println(" " + paint(Style.DIM, "no obvious exits"));
}
out.println(paint(Style.HEADING, frameBottom()));
}
@Override
public void setHud(Hud h) {
String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn()
+ " · light: " + (h.lightOn() ? "on" : "off") + " ]";
out.println(paint(Style.DIM, line));
}
private String section(String label, List<String> xs, Style st) {
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
for (int i = 0; i < xs.size(); i++) {
if (i > 0) {
sb.append(" · ");
}
sb.append(paint(st, xs.get(i)));
}
return sb.toString();
}
private String frameTop(String title) {
String tl = glyphs == GlyphMode.ASCII ? "+" : "";
String tr = glyphs == GlyphMode.ASCII ? "+" : "";
String h = glyphs == GlyphMode.ASCII ? "-" : "";
StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
.append(title.toUpperCase()).append(' ');
while (sb.length() < FRAME_WIDTH - 1) {
sb.append(h);
}
return sb.append(tr).toString();
}
private String frameBottom() {
String bl = glyphs == GlyphMode.ASCII ? "+" : "";
String br = glyphs == GlyphMode.ASCII ? "+" : "";
String h = glyphs == GlyphMode.ASCII ? "-" : "";
StringBuilder sb = new StringBuilder(bl);
while (sb.length() < FRAME_WIDTH - 1) {
sb.append(h);
}
return sb.append(br).toString();
}
private String paint(Style st, String s) {
if (!useColor) {
return s;
}
String code = switch (st) {
case HEADING -> "";
case ITEM -> "";
case NPC -> "";
case EXIT -> "";
case DANGER -> "";
case DIM -> "";
case PLAIN -> "";
};
return code.isEmpty() ? s : code + s + "";
}
}

View File

@@ -0,0 +1,64 @@
package thb.jeanluc.adventure.io;
import org.junit.jupiter.api.Test;
import thb.jeanluc.adventure.io.text.Hud;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.io.text.StyledText;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ConsoleIORenderTest {
private ConsoleIO io(ByteArrayOutputStream sink) {
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
return new ConsoleIO(new BufferedReader(new StringReader("")), ps,
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE);
}
@Test
void showRoom_drawsHeadingSectionsAndExits() {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
io(sink).showRoom(new RoomView("Kitchen", "A cold kitchen.",
List.of("Lamp"), List.of("Old Man"), List.of("north", "east")));
String out = sink.toString(StandardCharsets.UTF_8);
assertThat(out).contains("KITCHEN");
assertThat(out).contains("A cold kitchen.");
assertThat(out).contains("Lamp");
assertThat(out).contains("Old Man");
assertThat(out).contains("north").contains("east");
}
@Test
void print_colorOff_emitsNoAnsiEscapes() {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
io(sink).print(StyledText.builder().item("brass lamp").build());
assertThat(sink.toString(StandardCharsets.UTF_8)).doesNotContain("[");
}
@Test
void setHud_printsLocationGoldTurnAndLight() {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
io(sink).setHud(new Hud("Kitchen", 0, 7, false));
String out = sink.toString(StandardCharsets.UTF_8);
assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off");
}
@Test
void asciiGlyphMode_usesPlusDashFrame() {
ByteArrayOutputStream sink = new ByteArrayOutputStream();
PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8);
new ConsoleIO(new BufferedReader(new StringReader("")), ps,
ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.ASCII)
.showRoom(new RoomView("Cell", "x", List.of(), List.of(), List.of("north")));
String out = sink.toString(StandardCharsets.UTF_8);
assertThat(out).contains("+").contains("-");
assertThat(out).doesNotContain("");
}
}