feat(io): semantic styled-text model (Style, Span, StyledText)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:14:22 +02:00
parent 230177dfb5
commit d57a1ab9a4
4 changed files with 114 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
package thb.jeanluc.adventure.io.text;
/** A run of text carrying a single semantic {@link Style}. */
public record Span(String text, Style style) {
public Span {
if (text == null) {
throw new IllegalArgumentException("text must not be null");
}
if (style == null) {
throw new IllegalArgumentException("style must not be null");
}
}
}

View File

@@ -0,0 +1,4 @@
package thb.jeanluc.adventure.io.text;
/** Semantic output roles. Renderers map each role to concrete colours/attributes. */
public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM }

View File

@@ -0,0 +1,59 @@
package thb.jeanluc.adventure.io.text;
import java.util.ArrayList;
import java.util.List;
/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */
public final class StyledText {
private final List<Span> spans;
private StyledText(List<Span> spans) {
this.spans = List.copyOf(spans);
}
/** @return the spans in order (unmodifiable). */
public List<Span> spans() {
return spans;
}
/** @return all span text concatenated, without any styling. */
public String plainText() {
StringBuilder sb = new StringBuilder();
for (Span s : spans) {
sb.append(s.text());
}
return sb.toString();
}
/** @return a styled text of a single {@link Style#PLAIN} span. */
public static StyledText of(String s) {
return new StyledText(List.of(new Span(s, Style.PLAIN)));
}
public static Builder builder() {
return new Builder();
}
/** Fluent builder; one method per {@link Style} role. */
public static final class Builder {
private final List<Span> spans = new ArrayList<>();
private Builder add(String text, Style style) {
spans.add(new Span(text, style));
return this;
}
public Builder plain(String s) { return add(s, Style.PLAIN); }
public Builder heading(String s) { return add(s, Style.HEADING); }
public Builder item(String s) { return add(s, Style.ITEM); }
public Builder npc(String s) { return add(s, Style.NPC); }
public Builder exit(String s) { return add(s, Style.EXIT); }
public Builder danger(String s) { return add(s, Style.DANGER); }
public Builder dim(String s) { return add(s, Style.DIM); }
public StyledText build() {
return new StyledText(spans);
}
}
}

View File

@@ -0,0 +1,38 @@
package thb.jeanluc.adventure.io.text;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class StyledTextTest {
@Test
void builder_collectsSpansInOrder() {
StyledText t = StyledText.builder()
.plain("You take ").item("the brass lamp").plain(".").build();
assertThat(t.spans()).hasSize(3);
assertThat(t.spans().get(1).style()).isEqualTo(Style.ITEM);
assertThat(t.spans().get(1).text()).isEqualTo("the brass lamp");
}
@Test
void plainText_concatenatesAllSpanText() {
StyledText t = StyledText.builder()
.plain("You take ").item("the brass lamp").plain(".").build();
assertThat(t.plainText()).isEqualTo("You take the brass lamp.");
}
@Test
void of_createsSinglePlainSpan() {
StyledText t = StyledText.of("hello");
assertThat(t.spans()).hasSize(1);
assertThat(t.spans().getFirst().style()).isEqualTo(Style.PLAIN);
assertThat(t.plainText()).isEqualTo("hello");
}
@Test
void span_rejectsNulls() {
assertThatThrownBy(() -> new Span(null, Style.PLAIN))
.isInstanceOf(IllegalArgumentException.class);
}
}