diff --git a/common/build.gradle b/common/build.gradle
index 81a4f2a..e7a4cdf 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -22,6 +22,7 @@ dependencies {
implementation 'org.commonmark:commonmark:0.27.1'
implementation 'org.commonmark:commonmark-ext-yaml-front-matter:0.27.1'
+ implementation 'org.commonmark:commonmark-ext-gfm-tables:0.27.1'
}
diff --git a/common/src/main/java/rearth/oracle/OracleClient.java b/common/src/main/java/rearth/oracle/OracleClient.java
index 644e611..e8d3bfe 100644
--- a/common/src/main/java/rearth/oracle/OracleClient.java
+++ b/common/src/main/java/rearth/oracle/OracleClient.java
@@ -23,6 +23,7 @@
import rearth.oracle.tooltip.DocumentationTooltipHandler;
import rearth.oracle.ui.OracleScreen;
import rearth.oracle.ui.SearchScreen;
+import rearth.oracle.util.AudioPlayer;
import rearth.oracle.util.TitleLookup;
import java.util.*;
@@ -79,6 +80,7 @@ public static void init() {
ReloadListenerRegistry.register(PackType.CLIENT_RESOURCES, (ResourceManagerReloadListener) manager -> {
Oracle.LOGGER.info("Indexing Oracle Wiki Resources...");
+ AudioPlayer.release();
findAllResourceEntries(manager);
getOrCreateSearch(); // start search to begin indexing in advance
}, Identifier.fromNamespaceAndPath(Oracle.MOD_ID, "wiki_resources"));
diff --git a/common/src/main/java/rearth/oracle/ui/WikiBaseScreen.java b/common/src/main/java/rearth/oracle/ui/WikiBaseScreen.java
index 1dc9a17..9c8122d 100644
--- a/common/src/main/java/rearth/oracle/ui/WikiBaseScreen.java
+++ b/common/src/main/java/rearth/oracle/ui/WikiBaseScreen.java
@@ -6,6 +6,7 @@
import net.minecraft.network.chat.Component;
import org.jetbrains.annotations.Nullable;
import rearth.oracle.ui.widgets.UIComponent;
+import rearth.oracle.util.AudioPlayer;
import java.util.ArrayList;
import java.util.List;
@@ -96,6 +97,12 @@ private UIComponent topmostAt(double mouseX, double mouseY) {
return null;
}
+ @Override
+ public void removed() {
+ super.removed();
+ AudioPlayer.stop();
+ }
+
@Override
public void tick() {
super.tick();
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/AudioWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/AudioWidget.java
new file mode 100644
index 0000000..7ee49be
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/AudioWidget.java
@@ -0,0 +1,59 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+import rearth.oracle.util.AudioPlayer;
+
+public class AudioWidget extends FlowWidget {
+ private static final String CHAR_PLAY = "▶";
+ private static final String CHAR_STOP = "■";
+ private static final int BUTTON_SIZE = 16;
+
+ private final Identifier location;
+ private final LabelWidget glyph;
+ private boolean wasPlaying;
+
+ public AudioWidget(Identifier location, String displayName) {
+ super(Direction.HORIZONTAL);
+ this.location = location;
+ this.glyph = new LabelWidget(glyphText(false));
+
+ setSurface(WikiSurface.BEDROCK_PANEL_DARK);
+ setPadding(Insets.of(5, 7));
+ gap(5);
+ verticalAlignment(VerticalAlignment.CENTER);
+
+ ClickableWidget button = new ClickableWidget(glyph, b -> {
+ AudioPlayer.toggle(location);
+ refreshGlyph();
+ })
+ .fixedSize(BUTTON_SIZE, BUTTON_SIZE)
+ .centerChild()
+ .surfaces(WikiSurface.BEDROCK_PANEL, WikiSurface.BEDROCK_PANEL_HOVER,
+ WikiSurface.BEDROCK_PANEL_PRESSED, WikiSurface.BEDROCK_PANEL, WikiSurface.BEDROCK_PANEL_DISABLED);
+ button.setPadding(new Insets(1, 0, 0, 2));
+
+ super.child(button);
+ super.child(new LabelWidget(Component.literal(displayName).withStyle(ChatFormatting.GRAY)));
+ }
+
+ private static Component glyphText(boolean playing) {
+ return Component.literal(playing ? CHAR_STOP : CHAR_PLAY).withStyle(ChatFormatting.DARK_GRAY);
+ }
+
+ private void refreshGlyph() {
+ boolean playing = AudioPlayer.isPlaying(location);
+ if (playing == wasPlaying) return;
+
+ wasPlaying = playing;
+ glyph.text(glyphText(playing));
+ }
+
+ @Override
+ protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ refreshGlyph();
+ super.renderContent(context, mouseX, mouseY, delta);
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/BlockQuoteWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/BlockQuoteWidget.java
new file mode 100644
index 0000000..739f3b4
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/BlockQuoteWidget.java
@@ -0,0 +1,21 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+
+public class BlockQuoteWidget extends FlowWidget {
+ private static final int RULE_COLOR = 0x80777777;
+ private static final int RULE_WIDTH = 2;
+
+ public BlockQuoteWidget() {
+ super(Direction.VERTICAL);
+
+ gap(2);
+ setPadding(Insets.of(2, 0, 2, 10));
+ }
+
+ @Override
+ protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ context.fill(x + 2, y, x + 2 + RULE_WIDTH, y + height, RULE_COLOR);
+ super.renderContent(context, mouseX, mouseY, delta);
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java
index 69606f2..4a6bcf4 100644
--- a/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java
+++ b/common/src/main/java/rearth/oracle/ui/widgets/CalloutWidget.java
@@ -1,13 +1,14 @@
package rearth.oracle.ui.widgets;
+import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.resources.sounds.SimpleSoundInstance;
import net.minecraft.network.chat.Component;
-import net.minecraft.ChatFormatting;
+import net.minecraft.sounds.SoundEvents;
+import org.jetbrains.annotations.Nullable;
import rearth.oracle.util.CalloutVariant;
-import java.util.Locale;
-
/**
* Stylised callout block used by {@code } markdown tags.
* Renders a panel for the body content with a small overlapping title chip
@@ -16,74 +17,135 @@
*/
public class CalloutWidget extends FlowWidget {
private static final int BODY_TEXT_COLOR = 0xFF555555;
-
+ private static final int LABEL_OVERLAP = 6;
+ private static final int LABEL_PAD_X = 12;
+ private static final int LABEL_PAD_Y = 9;
+
+ private static final String CH_CLOSED = " >";
+ private static final String CH_OPEN = " v";
+
private final CalloutVariant variant;
+ private final Component title;
+ private final boolean collapsible;
private final FlowWidget body;
-
- @SuppressWarnings("this-escape")
- public CalloutWidget(CalloutVariant variant) {
+
+ private boolean expanded;
+ private int labelX, labelY, labelWidth, labelHeight;
+
+ public CalloutWidget(CalloutVariant variant, @Nullable Component title, boolean collapsible, boolean collapsed) {
super(Direction.VERTICAL);
this.variant = variant;
+ this.title = title != null ? title : variant.getTitle();
+ this.collapsible = collapsible;
+ this.expanded = !collapsed;
this.body = FlowWidget.vertical();
body.setSurface(WikiSurface.BEDROCK_PANEL);
body.setPadding(Insets.of(14, 8, 10, 10)); // extra top so the chip doesn't overlap the text
+ body.setVisible(expanded);
super.child(body);
}
-
+
public CalloutWidget addBodyChild(UIComponent child) {
tintBodyText(child);
body.child(child);
return this;
}
-
+
private void tintBodyText(UIComponent child) {
if (child instanceof LabelWidget label) {
label.color(BODY_TEXT_COLOR);
- } else if (child instanceof FlowWidget flow) {
- for (var nested : flow.children()) tintBodyText(nested);
+ } else if (child instanceof TableWidget table) {
+ table.color(BODY_TEXT_COLOR);
+ } else if (child instanceof FlowWidget flow && flow.getSurface().isNone()) {
+ for (var nested : flow.children()) {
+ tintBodyText(nested);
+ }
}
}
-
+
+ public boolean isExpanded() {
+ return expanded;
+ }
+
+ public void setExpanded(boolean expanded) {
+ if (this.expanded == expanded) return;
+ this.expanded = expanded;
+ body.setVisible(expanded);
+ requestLayout();
+ }
+
+ private Component getLabelTitle() {
+ var text = title.copy().withStyle(ChatFormatting.WHITE);
+ if (collapsible) text.append(Component.literal(expanded ? CH_OPEN : CH_CLOSED));
+ return text;
+ }
+
+ private int getLabelHeight() {
+ return Minecraft.getInstance().font.lineHeight + LABEL_PAD_Y;
+ }
+
@Override
public int getPreferredWidth(int widthHint) {
if (widthHint > 0) return widthHint;
return super.getPreferredWidth(widthHint);
}
-
+
@Override
public int getPreferredHeight(int widthHint) {
+ if (!expanded) return getLabelHeight();
if (widthHint > 0) return body.getPreferredHeight(calloutWidth(widthHint));
return super.getPreferredHeight(widthHint);
}
-
+
@Override
public void layout(int parentWidthHint, int parentHeightHint) {
width = parentWidthHint > 0 ? parentWidthHint : getPreferredWidth(-1);
int bodyWidth = calloutWidth(width);
- int bodyHeight = body.getPreferredHeight(bodyWidth);
- height = bodyHeight;
- body.setPosition(x + (width - bodyWidth) / 2, y);
- body.setLayoutSize(bodyWidth, bodyHeight);
- body.layout(bodyWidth, bodyHeight);
+ int bodyX = x + (width - bodyWidth) / 2;
+
+ if (expanded) {
+ int bodyHeight = body.getPreferredHeight(bodyWidth);
+ height = bodyHeight;
+ body.setPosition(bodyX, y);
+ body.setLayoutSize(bodyWidth, bodyHeight);
+ body.layout(bodyWidth, bodyHeight);
+ } else {
+ height = getLabelHeight();
+ body.setPosition(bodyX, y + LABEL_OVERLAP);
+ body.setLayoutSize(bodyWidth, 0);
+ }
+
+ var tr = Minecraft.getInstance().font;
+ labelWidth = tr.width(getLabelTitle()) + LABEL_PAD_X;
+ labelHeight = getLabelHeight();
+ labelX = bodyX - LABEL_OVERLAP;
+ labelY = body.getY() - LABEL_OVERLAP;
}
-
+
private int calloutWidth(int availableWidth) {
return Math.min(Math.max(1, availableWidth), Math.max(120, (int) (availableWidth * 0.8f)));
}
-
+
@Override
protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
super.renderContent(context, mouseX, mouseY, delta);
// overlapping title chip rendered on top
var tr = Minecraft.getInstance().font;
- var key = "oracle_index.callout." + this.variant.name().toLowerCase(Locale.ROOT);
- var title = Component.translatable(key).withStyle(ChatFormatting.WHITE);
- int textW = tr.width(title);
- int chipW = textW + 12;
- int chipH = tr.lineHeight + 9;
- int chipX = body.getX();
- int chipY = body.getY();
- this.variant.getSurface().render(context, chipX - 6, chipY - 6, chipW, chipH);
- context.text(tr, title, chipX, chipY, 0xFFFFFFFF, false);
+ this.variant.getSurface().render(context, labelX, labelY, labelWidth, labelHeight);
+ context.text(tr, getLabelTitle(), labelX + LABEL_OVERLAP, labelY + LABEL_OVERLAP, 0xFFFFFFFF, false);
+ }
+
+ @Override
+ public boolean handleClick(double mouseX, double mouseY, int button) {
+ if (collapsible && button == 0 && isOverLabel(mouseX, mouseY)) {
+ setExpanded(!expanded);
+ Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0f));
+ return true;
+ }
+ return super.handleClick(mouseX, mouseY, button);
+ }
+
+ private boolean isOverLabel(double mouseX, double mouseY) {
+ return mouseX >= labelX && mouseX < labelX + labelWidth && mouseY >= labelY && mouseY < labelY + labelHeight;
}
}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/CodeBlockWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/CodeBlockWidget.java
new file mode 100644
index 0000000..2811a62
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/CodeBlockWidget.java
@@ -0,0 +1,50 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.MutableComponent;
+import org.jetbrains.annotations.Nullable;
+
+public class CodeBlockWidget extends FlowWidget {
+ private static final int CODE_COLOR = 0xFFB0B4BC;
+ private static final int DIVIDER_COLOR = 0x40FFFFFF;
+
+ @Nullable
+ private final LabelWidget header;
+
+ public CodeBlockWidget(@Nullable String fileName, @Nullable String language, String code) {
+ super(Direction.VERTICAL);
+ setSurface(WikiSurface.BEDROCK_PANEL_DARK);
+ setPadding(Insets.of(6));
+ gap(4);
+
+ MutableComponent headerText = headerText(fileName, language);
+ this.header = headerText == null ? null : new LabelWidget(headerText);
+ if (header != null) super.child(header);
+
+ super.child(new LabelWidget(Component.literal(code.stripTrailing())).color(CODE_COLOR).lineSpacing(1));
+ }
+
+ @Nullable
+ private static MutableComponent headerText(@Nullable String fileName, @Nullable String language) {
+ boolean hasFile = fileName != null && !fileName.isBlank();
+ boolean hasLanguage = language != null && !language.isBlank();
+ if (!hasFile && !hasLanguage) return null;
+
+ if (!hasFile) {
+ return Component.literal(language).withStyle(ChatFormatting.GRAY);
+ }
+
+ return Component.literal(fileName).withStyle(ChatFormatting.GOLD);
+ }
+
+ @Override
+ protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ super.renderContent(context, mouseX, mouseY, delta);
+ if (header == null) return;
+
+ int lineY = header.getY() + header.getHeight() + 1;
+ context.fill(x + padding.left(), lineY, x + width - padding.right(), lineY + 1, DIVIDER_COLOR);
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/CodeTabsWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/CodeTabsWidget.java
new file mode 100644
index 0000000..3839c04
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/CodeTabsWidget.java
@@ -0,0 +1,76 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.resources.sounds.SimpleSoundInstance;
+import net.minecraft.network.chat.Component;
+import net.minecraft.sounds.SoundEvents;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class CodeTabsWidget extends FlowWidget {
+ private static final int CODE_COLOR = 0xFFB0B4BC;
+
+ private final List tabs;
+ private final List tabLabels = new ArrayList<>();
+ private final List tabButtons = new ArrayList<>();
+ private final LabelWidget codeLabel;
+
+ private int active;
+
+ public CodeTabsWidget(List tabs) {
+ super(Direction.VERTICAL);
+ this.tabs = List.copyOf(tabs);
+ this.codeLabel = new LabelWidget(codeText(0)).color(CODE_COLOR).lineSpacing(1);
+
+ FlowWidget header = FlowWidget.horizontal().gap(-1);
+ for (int i = 0; i < this.tabs.size(); i++) {
+ int index = i;
+ LabelWidget label = new LabelWidget(tabTitle(i));
+ tabLabels.add(label);
+
+ ClickableWidget button = new ClickableWidget(label, b -> select(index))
+ .centerChild()
+ .selected(i == 0)
+ .surfaces(WikiSurface.BEDROCK_PANEL_DARK, WikiSurface.BEDROCK_PANEL_HOVER,
+ WikiSurface.BEDROCK_PANEL_PRESSED, WikiSurface.BEDROCK_PANEL, WikiSurface.BEDROCK_PANEL_DARK);
+ button.setPadding(Insets.of(5, 10));
+ tabButtons.add(button);
+ header.child(button);
+ }
+
+ FlowWidget body = FlowWidget.vertical();
+ body.setSurface(WikiSurface.BEDROCK_PANEL_DARK);
+ body.setPadding(Insets.of(6));
+ body.child(codeLabel);
+
+ super.child(header);
+ super.child(body);
+ }
+
+ private Component tabTitle(int index) {
+ return Component.literal(tabs.get(index).title()).withStyle(ChatFormatting.DARK_GRAY);
+ }
+
+ private Component codeText(int index) {
+ return Component.literal(tabs.get(index).code().stripTrailing());
+ }
+
+ private void select(int index) {
+ if (index == active || index < 0 || index >= tabs.size()) return;
+
+ active = index;
+ for (int i = 0; i < tabs.size(); i++) {
+ tabLabels.get(i).text(tabTitle(i));
+ tabButtons.get(i).selected(i == index);
+ }
+ codeLabel.text(codeText(index));
+
+ Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F));
+ requestLayout();
+ }
+
+ public record Tab(String title, String code) {
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/FigureWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/FigureWidget.java
new file mode 100644
index 0000000..696a0cd
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/FigureWidget.java
@@ -0,0 +1,26 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+
+public class FigureWidget extends FlowWidget {
+ private final HorizontalAlignment alignment;
+
+ public FigureWidget(UIComponent image, Component caption, HorizontalAlignment alignment) {
+ super(Direction.VERTICAL);
+
+ this.alignment = alignment;
+
+ gap(3);
+ horizontalAlignment(HorizontalAlignment.CENTER);
+ super.child(image);
+ super.child(new LabelWidget(caption.copy().withStyle(ChatFormatting.ITALIC, ChatFormatting.DARK_GRAY))
+ .textAlignment(HorizontalAlignment.CENTER)
+ .lineSpacing(1));
+ }
+
+ @Override
+ public HorizontalAlignment getOverrideAlignment() {
+ return alignment;
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/LabelWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/LabelWidget.java
index 21f1689..78f8dca 100644
--- a/common/src/main/java/rearth/oracle/ui/widgets/LabelWidget.java
+++ b/common/src/main/java/rearth/oracle/ui/widgets/LabelWidget.java
@@ -3,10 +3,12 @@
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.nbt.Tag;
import net.minecraft.network.chat.ClickEvent;
-import net.minecraft.util.FormattedCharSequence;
-import net.minecraft.network.chat.Style;
import net.minecraft.network.chat.Component;
+import net.minecraft.network.chat.HoverEvent;
+import net.minecraft.network.chat.Style;
+import net.minecraft.util.FormattedCharSequence;
import net.minecraft.util.Mth;
import java.util.ArrayList;
@@ -22,60 +24,65 @@
* for URL and wiki-link styles via a {@link Predicate} that can veto navigation.
*/
public class LabelWidget extends UIComponent {
-
private Component text;
private float scale = 1.0f;
private int color = 0xFFFFFFFF;
private int lineSpacing = 0;
+ private FlowWidget.HorizontalAlignment textAlignment = FlowWidget.HorizontalAlignment.LEFT;
/**
* -1 means "use the layout-supplied hint".
*/
private int wrapWidth = -1;
private boolean fillWidth = false;
-
+
private Predicate linkHandler;
-
+
// Cached wrap result
private List wrappedLines = new ArrayList<>();
private int lastWrapWidth = -1;
private Component lastWrappedText;
private float lastWrapScale = -1;
-
+
public LabelWidget(Component text) {
this.text = text;
}
-
+
public LabelWidget text(Component text) {
this.text = text;
invalidateWrap();
return this;
}
-
+
public Component text() {
return text;
}
-
+
public LabelWidget scale(float scale) {
this.scale = scale;
invalidateWrap();
return this;
}
-
+
public float scale() {
return scale;
}
-
+
public LabelWidget color(int argb) {
this.color = argb;
return this;
}
-
+
public LabelWidget lineSpacing(int lineSpacing) {
this.lineSpacing = lineSpacing;
invalidateWrap();
return this;
}
-
+
+ public LabelWidget textAlignment(FlowWidget.HorizontalAlignment alignment) {
+ this.textAlignment = alignment;
+ return this;
+ }
+
/**
* Set an explicit wrap width (in unscaled pixels). -1 means use the layout-supplied hint.
*/
@@ -84,27 +91,27 @@ public LabelWidget wrapWidth(int wrapWidth) {
invalidateWrap();
return this;
}
-
+
public LabelWidget fillWidth() {
this.fillWidth = true;
return this;
}
-
+
public LabelWidget linkHandler(Predicate handler) {
this.linkHandler = handler;
return this;
}
-
+
private void invalidateWrap() {
lastWrapWidth = -1;
lastWrappedText = null;
lastWrapScale = -1;
}
-
+
private Font textRenderer() {
return Minecraft.getInstance().font;
}
-
+
/**
* Width in unscaled font pixels available for wrapping, given a layout hint.
*/
@@ -116,7 +123,7 @@ private int effectiveWrapWidthPx(int widthHint) {
// wrap is done in the unscaled font space, so undo the scale
return Math.max(1, (int) Math.floor(avail / scale));
}
-
+
private List wrap(int widthHint) {
int wrapPx = effectiveWrapWidthPx(widthHint);
if (wrapPx == lastWrapWidth && text == lastWrappedText && scale == lastWrapScale && !wrappedLines.isEmpty()) {
@@ -128,7 +135,7 @@ private List wrap(int widthHint) {
wrappedLines = textRenderer().split(text, wrapPx);
return wrappedLines;
}
-
+
@Override
public int getPreferredWidth(int widthHint) {
if (preferredWidth > 0) return preferredWidth;
@@ -138,7 +145,7 @@ public int getPreferredWidth(int widthHint) {
for (var line : lines) max = Math.max(max, textRenderer().width(line));
return Mth.ceil(max * scale);
}
-
+
@Override
public int getPreferredHeight(int widthHint) {
if (preferredHeight > 0) return preferredHeight;
@@ -147,13 +154,13 @@ public int getPreferredHeight(int widthHint) {
int total = n * textRenderer().lineHeight + Math.max(0, n - 1) * lineSpacing;
return Mth.ceil(total * scale);
}
-
+
@Override
public void layout(int parentWidthHint, int parentHeightHint) {
// Ensure wrap is valid for current layout; size remains externally driven.
wrap(parentWidthHint);
}
-
+
@Override
protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
var lines = wrap(width > 0 ? width : Integer.MAX_VALUE / 2);
@@ -170,11 +177,20 @@ protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouse
int lineHeight = tr.lineHeight + lineSpacing;
for (int i = 0; i < lines.size(); i++) {
var line = lines.get(i);
- context.text(tr, line, baseX, baseY + i * lineHeight, color, false);
+ context.text(tr, line, baseX + getLineOffset(line), baseY + i * lineHeight, color, false);
}
if (scaled) matrices.popMatrix();
}
-
+
+ @Override
+ public List tooltip(int mouseX, int mouseY) {
+ var style = styleAt(mouseX, mouseY);
+ if (style != null && style.getHoverEvent() instanceof HoverEvent.ShowText showText) {
+ return List.of(showText.value());
+ }
+ return super.tooltip(mouseX, mouseY);
+ }
+
@Override
public boolean handleClick(double mouseX, double mouseY, int button) {
if (linkHandler == null || button != 0) return super.handleClick(mouseX, mouseY, button);
@@ -185,14 +201,23 @@ public boolean handleClick(double mouseX, double mouseY, int button) {
if (click instanceof ClickEvent.OpenUrl openUrl) {
destination = openUrl.uri().toString();
} else if (click instanceof ClickEvent.Custom custom) {
- destination = custom.payload().flatMap(tag -> tag.asString()).orElse(null);
+ destination = custom.payload().flatMap(Tag::asString).orElse(null);
} else {
return super.handleClick(mouseX, mouseY, button);
}
if (destination != null && linkHandler.test(destination)) return true;
return super.handleClick(mouseX, mouseY, button);
}
-
+
+ private int getLineOffset(FormattedCharSequence line) {
+ if (textAlignment == FlowWidget.HorizontalAlignment.LEFT || width <= 0) return 0;
+ int available = Mth.floor(width / scale);
+ int lineWidth = textRenderer().width(line);
+ int slack = available - lineWidth;
+ if (slack <= 0) return 0;
+ return textAlignment == FlowWidget.HorizontalAlignment.CENTER ? slack / 2 : slack;
+ }
+
/**
* Returns the {@link Style} under the given mouse position, or null.
*/
@@ -206,11 +231,13 @@ public Style styleAt(double mouseX, double mouseY) {
int lineHeight = tr.lineHeight + lineSpacing;
int lineIndex = (int) Math.floor(localY / lineHeight);
if (lineIndex < 0 || lineIndex >= lines.size()) return null;
+ double lineX = localX - getLineOffset(lines.get(lineIndex));
+ if (lineX < 0) return null;
float[] measuredWidth = {0};
Style[] result = {null};
lines.get(lineIndex).accept((position, style, codePoint) -> {
measuredWidth[0] += tr.width(new String(Character.toChars(codePoint)));
- if (measuredWidth[0] >= localX) {
+ if (measuredWidth[0] >= lineX) {
result[0] = style;
return false;
}
@@ -218,5 +245,5 @@ public Style styleAt(double mouseX, double mouseY) {
});
return result[0];
}
-
+
}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/TableWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/TableWidget.java
new file mode 100644
index 0000000..7530fc0
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/TableWidget.java
@@ -0,0 +1,190 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.network.chat.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+
+public class TableWidget extends UIComponent {
+ private static final int BORDER = 1;
+ private static final int CELL_PAD_X = 4;
+ private static final int CELL_PAD_Y = 3;
+ private static final int MIN_COLUMN_CONTENT = 24;
+
+ private static final int GRID_COLOR = 0x40FFFFFF;
+ private static final int HEADER_RULE_COLOR = 0x80FFFFFF;
+ private static final int HEADER_FILL_COLOR = 0x18FFFFFF;
+
+ public record Cell(Component text, FlowWidget.HorizontalAlignment alignment) {
+ }
+
+ private final List> rows = new ArrayList<>();
+ private final boolean hasHeader;
+ private final int columns;
+
+ private int[] columnContentWidths;
+ private int[] rowHeights;
+ private int tableWidth;
+ private int tableHeight;
+ private int lastLayoutWidth = -1;
+
+ public TableWidget(List> cells, boolean hasHeader, Predicate linkHandler) {
+ this.hasHeader = hasHeader;
+ int widest = 0;
+ for (var row : cells) widest = Math.max(widest, row.size());
+ this.columns = Math.max(1, widest);
+
+ for (var row : cells) {
+ var labels = new ArrayList(columns);
+ for (int i = 0; i < columns; i++) {
+ var cell = i < row.size() ? row.get(i) : new Cell(Component.empty(), FlowWidget.HorizontalAlignment.LEFT);
+ labels.add(new LabelWidget(cell.text())
+ .textAlignment(cell.alignment())
+ .linkHandler(linkHandler));
+ }
+ rows.add(labels);
+ }
+ }
+
+ public TableWidget color(int argb) {
+ for (var row : rows) {
+ for (var label : row) {
+ label.color(argb);
+ }
+ }
+ return this;
+ }
+
+ private void resolveColumns(int widthHint) {
+ int available = widthHint > 0 ? widthHint : Integer.MAX_VALUE / 4;
+ if (columnContentWidths != null && lastLayoutWidth == available) return;
+ lastLayoutWidth = available;
+
+ var widths = new int[columns];
+ for (var row : rows) {
+ for (int c = 0; c < columns; c++) {
+ widths[c] = Math.max(widths[c], row.get(c).getPreferredWidth(-1));
+ }
+ }
+
+ // Trim the widest column down to the runner-up until the table fits, so columns lose
+ // space in the order that hurts readability least.
+ int chrome = (columns + 1) * BORDER + columns * CELL_PAD_X * 2;
+ int excess = chrome + sum(widths) - available;
+ while (excess > 0) {
+ int widest = 0;
+ for (int c = 1; c < columns; c++) if (widths[c] > widths[widest]) widest = c;
+ if (widths[widest] <= MIN_COLUMN_CONTENT) break;
+
+ int runnerUp = MIN_COLUMN_CONTENT;
+ for (int c = 0; c < columns; c++) if (c != widest) runnerUp = Math.max(runnerUp, widths[c]);
+ int floor = Math.max(MIN_COLUMN_CONTENT, Math.min(runnerUp, widths[widest] - 1));
+
+ int reduction = Math.min(excess, widths[widest] - floor);
+ if (reduction <= 0) break;
+ widths[widest] -= reduction;
+ excess -= reduction;
+ }
+
+ this.columnContentWidths = widths;
+ this.tableWidth = sum(widths) + chrome;
+ this.rowHeights = new int[rows.size()];
+ for (int r = 0; r < rows.size(); r++) {
+ int tallest = 0;
+ for (int c = 0; c < columns; c++) {
+ tallest = Math.max(tallest, rows.get(r).get(c).getPreferredHeight(widths[c]));
+ }
+ rowHeights[r] = tallest + CELL_PAD_Y * 2;
+ }
+ this.tableHeight = sum(rowHeights) + (rows.size() + 1) * BORDER;
+ }
+
+ private static int sum(int[] values) {
+ int total = 0;
+ for (var value : values) total += value;
+ return total;
+ }
+
+ @Override
+ public int getPreferredWidth(int widthHint) {
+ resolveColumns(widthHint);
+ return tableWidth;
+ }
+
+ @Override
+ public int getPreferredHeight(int widthHint) {
+ resolveColumns(widthHint);
+ return tableHeight;
+ }
+
+ @Override
+ public void layout(int parentWidthHint, int parentHeightHint) {
+ resolveColumns(parentWidthHint);
+ int cellY = y + BORDER;
+ for (int r = 0; r < rows.size(); r++) {
+ int cellX = x + BORDER;
+ for (int c = 0; c < columns; c++) {
+ var label = rows.get(r).get(c);
+ int contentWidth = columnContentWidths[c];
+ label.setPosition(cellX + CELL_PAD_X, cellY + CELL_PAD_Y);
+ label.setLayoutSize(contentWidth, rowHeights[r] - CELL_PAD_Y * 2);
+ label.layout(contentWidth, rowHeights[r] - CELL_PAD_Y * 2);
+ cellX += contentWidth + CELL_PAD_X * 2 + BORDER;
+ }
+ cellY += rowHeights[r] + BORDER;
+ }
+ }
+
+ @Override
+ protected void renderContent(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
+ if (columnContentWidths == null) return;
+
+ // header band, so the first row reads as headings even without a surface behind the table
+ if (hasHeader && !rows.isEmpty()) {
+ context.fill(x, y, x + tableWidth, y + rowHeights[0] + BORDER * 2, HEADER_FILL_COLOR);
+ }
+
+ // horizontal rules, the one under the header row is brighter
+ int lineY = y;
+ for (int r = 0; r <= rows.size(); r++) {
+ int color = hasHeader && r == 1 ? HEADER_RULE_COLOR : GRID_COLOR;
+ context.fill(x, lineY, x + tableWidth, lineY + BORDER, color);
+ if (r < rows.size()) lineY += rowHeights[r] + BORDER;
+ }
+
+ // vertical rules
+ int lineX = x;
+ for (int c = 0; c <= columns; c++) {
+ context.fill(lineX, y, lineX + BORDER, y + tableHeight, GRID_COLOR);
+ if (c < columns) lineX += columnContentWidths[c] + CELL_PAD_X * 2 + BORDER;
+ }
+
+ for (var row : rows) {
+ for (var label : row) label.render(context, mouseX, mouseY, delta);
+ }
+ }
+
+ @Override
+ public boolean handleClick(double mouseX, double mouseY, int button) {
+ for (var row : rows) {
+ for (var label : row) {
+ if (label.isInBounds(mouseX, mouseY) && label.handleClick(mouseX, mouseY, button)) return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public List tooltip(int mouseX, int mouseY) {
+ for (var row : rows) {
+ for (var label : row) {
+ if (!label.isInBounds(mouseX, mouseY)) continue;
+ var tip = label.tooltip(mouseX, mouseY);
+ if (tip != null && !tip.isEmpty()) return tip;
+ }
+ }
+ return super.tooltip(mouseX, mouseY);
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/UIComponent.java b/common/src/main/java/rearth/oracle/ui/widgets/UIComponent.java
index b5beddb..ed31cb9 100644
--- a/common/src/main/java/rearth/oracle/ui/widgets/UIComponent.java
+++ b/common/src/main/java/rearth/oracle/ui/widgets/UIComponent.java
@@ -172,6 +172,10 @@ public Insets getPadding() {
return padding;
}
+ public WikiSurface getSurface() {
+ return surface;
+ }
+
public boolean isVisible() {
return visible;
}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/VideoEmbedWidget.java b/common/src/main/java/rearth/oracle/ui/widgets/VideoEmbedWidget.java
new file mode 100644
index 0000000..7c503bd
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/ui/widgets/VideoEmbedWidget.java
@@ -0,0 +1,35 @@
+package rearth.oracle.ui.widgets;
+
+import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.Component;
+
+import java.util.function.Predicate;
+
+public class VideoEmbedWidget extends FlowWidget {
+ private static final String YOUTUBE_URL = "https://www.youtube.com/watch?v=";
+ private static final String PLAY_ICON = "▶";
+
+ public VideoEmbedWidget(String videoId, Predicate linkHandler) {
+ super(Direction.VERTICAL);
+
+ String url = YOUTUBE_URL + videoId;
+ FlowWidget caption = FlowWidget.horizontal().gap(6);
+ caption.verticalAlignment(VerticalAlignment.CENTER);
+ caption.child(new LabelWidget(Component.literal(PLAY_ICON).withStyle(ChatFormatting.RED)).scale(1.5F));
+ caption.child(new LabelWidget(Component.translatable("oracle_index.video.watch").withStyle(ChatFormatting.DARK_GRAY)));
+
+ ClickableWidget button = new ClickableWidget(caption, b -> linkHandler.test(url))
+ .centerChild()
+ .surfaces(WikiSurface.BEDROCK_PANEL, WikiSurface.BEDROCK_PANEL_HOVER,
+ WikiSurface.BEDROCK_PANEL_PRESSED, WikiSurface.BEDROCK_PANEL, WikiSurface.BEDROCK_PANEL_DISABLED);
+ button.setPadding(Insets.of(10, 16));
+
+ horizontalAlignment(HorizontalAlignment.CENTER);
+ super.child(button);
+ }
+
+ @Override
+ public HorizontalAlignment getOverrideAlignment() {
+ return HorizontalAlignment.CENTER;
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java b/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java
index bbcff1e..16c8b7d 100644
--- a/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java
+++ b/common/src/main/java/rearth/oracle/ui/widgets/WikiSurface.java
@@ -15,6 +15,7 @@ public enum WikiSurface {
BEDROCK_PANEL_HOVER(ninePatch("bedrock_panel_hover")),
BEDROCK_PANEL_PRESSED(ninePatch("bedrock_panel_pressed")),
BEDROCK_PANEL_NOTE(ninePatch("bedrock_panel_note")),
+ BEDROCK_PANEL_IMPORTANT(ninePatch("bedrock_panel_important")),
BEDROCK_PANEL_WARNING(ninePatch("bedrock_panel_warning")),
BEDROCK_PANEL_DANGER(ninePatch("bedrock_panel_danger")),
BEDROCK_PANEL_DARK(ninePatch("bedrock_panel_dark")),
diff --git a/common/src/main/java/rearth/oracle/util/AudioPlayer.java b/common/src/main/java/rearth/oracle/util/AudioPlayer.java
new file mode 100644
index 0000000..8197f4b
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/util/AudioPlayer.java
@@ -0,0 +1,165 @@
+package rearth.oracle.util;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.resources.sounds.Sound;
+import net.minecraft.client.resources.sounds.SoundInstance;
+import net.minecraft.client.sounds.ChannelAccess.ChannelHandle;
+import net.minecraft.client.sounds.SoundEngine.PlayResult;
+import net.minecraft.client.sounds.SoundManager;
+import net.minecraft.client.sounds.WeighedSoundEvents;
+import net.minecraft.resources.Identifier;
+import net.minecraft.server.packs.resources.Resource;
+import net.minecraft.sounds.SoundSource;
+import net.minecraft.util.valueproviders.ConstantFloat;
+import org.jetbrains.annotations.Nullable;
+import rearth.oracle.Oracle;
+
+import java.util.Locale;
+import java.util.Optional;
+
+public final class AudioPlayer {
+ private static final int ATTENUATION_DISTANCE = 16;
+
+ @Nullable
+ private static WikiSoundInstance playing;
+
+ private AudioPlayer() {
+ }
+
+ public static boolean isPlaying(Identifier location) {
+ if (playing == null || !playing.getIdentifier().equals(location)) return false;
+
+ ChannelHandle handle = Minecraft.getInstance().getSoundManager().soundEngine.instanceToChannel.get(playing);
+ return handle != null && !handle.isStopped();
+ }
+
+ public static boolean toggle(Identifier location) {
+ if (isPlaying(location)) {
+ stop();
+ return false;
+ }
+ return play(location);
+ }
+
+ public static boolean play(Identifier location) {
+ if (!location.getPath().toLowerCase(Locale.ROOT).endsWith(".ogg")) {
+ Oracle.LOGGER.warn("Unsupported wiki audio format: {}", location);
+ return false;
+ }
+
+ Optional resource = Minecraft.getInstance().getResourceManager().getResource(location);
+ if (resource.isEmpty()) {
+ Oracle.LOGGER.warn("Wiki audio not found: {}", location);
+ return false;
+ }
+
+ SoundManager soundManager = Minecraft.getInstance().getSoundManager();
+ soundManager.soundCache.putIfAbsent(location, resource.get());
+
+ stop();
+
+ WikiSoundInstance instance = new WikiSoundInstance(location);
+ if (soundManager.play(instance) == PlayResult.NOT_STARTED) return false;
+
+ playing = instance;
+ return true;
+ }
+
+ public static void stop() {
+ if (playing == null) return;
+
+ Minecraft.getInstance().getSoundManager().stop(playing);
+ playing = null;
+ }
+
+ public static void release() {
+ stop();
+ }
+
+ private static class WikiSound extends Sound {
+ private WikiSound(Identifier location) {
+ super(location, ConstantFloat.of(1.0F), ConstantFloat.of(1.0F), 1, Type.FILE, false, false, ATTENUATION_DISTANCE);
+ }
+
+ @Override
+ public Identifier getPath() {
+ return getLocation();
+ }
+ }
+
+ private static class WikiSoundInstance implements SoundInstance {
+ private final Sound sound;
+ private final WeighedSoundEvents events;
+
+ WikiSoundInstance(Identifier location) {
+ this.sound = new WikiSound(location);
+ this.events = new WeighedSoundEvents(location, null);
+ this.events.addSound(this.sound);
+ }
+
+ @Override
+ public Identifier getIdentifier() {
+ return sound.getLocation();
+ }
+
+ @Override
+ public WeighedSoundEvents resolve(SoundManager soundManager) {
+ return events;
+ }
+
+ @Override
+ public Sound getSound() {
+ return sound;
+ }
+
+ @Override
+ public SoundSource getSource() {
+ return SoundSource.MASTER;
+ }
+
+ @Override
+ public boolean isLooping() {
+ return false;
+ }
+
+ @Override
+ public boolean isRelative() {
+ return true;
+ }
+
+ @Override
+ public int getDelay() {
+ return 0;
+ }
+
+ @Override
+ public float getVolume() {
+ return 1.0F;
+ }
+
+ @Override
+ public float getPitch() {
+ return 1.0F;
+ }
+
+ @Override
+ public double getX() {
+ return 0;
+ }
+
+ @Override
+ public double getY() {
+ return 0;
+ }
+
+ @Override
+ public double getZ() {
+ return 0;
+ }
+
+ @Override
+ public Attenuation getAttenuation() {
+ return Attenuation.NONE;
+ }
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/util/CalloutVariant.java b/common/src/main/java/rearth/oracle/util/CalloutVariant.java
index d5bb57b..2207b36 100644
--- a/common/src/main/java/rearth/oracle/util/CalloutVariant.java
+++ b/common/src/main/java/rearth/oracle/util/CalloutVariant.java
@@ -1,13 +1,17 @@
package rearth.oracle.util;
+import net.minecraft.network.chat.Component;
import rearth.oracle.ui.widgets.WikiSurface;
+import java.util.Locale;
+
public enum CalloutVariant {
- DEFAULT(WikiSurface.BEDROCK_PANEL_NOTE),
- INFO(WikiSurface.BEDROCK_PANEL_PRESSED),
+ NOTE(WikiSurface.BEDROCK_PANEL_NOTE),
+ TIP(WikiSurface.BEDROCK_PANEL_PRESSED),
+ IMPORTANT(WikiSurface.BEDROCK_PANEL_IMPORTANT),
WARNING(WikiSurface.BEDROCK_PANEL_WARNING),
- DANGER(WikiSurface.BEDROCK_PANEL_DANGER);
-
+ CAUTION(WikiSurface.BEDROCK_PANEL_DANGER);
+
private final WikiSurface surface;
CalloutVariant(WikiSurface surface) {
@@ -17,4 +21,20 @@ public enum CalloutVariant {
public WikiSurface getSurface() {
return surface;
}
+
+ public Component getTitle() {
+ return Component.translatable("oracle_index.callout." + name().toLowerCase(Locale.ROOT));
+ }
+
+ public static CalloutVariant byName(String name, CalloutVariant fallback) {
+ if (name == null) return fallback;
+ return switch (name.trim().toLowerCase(Locale.ROOT)) {
+ case "default", "info", "note" -> NOTE;
+ case "important" -> IMPORTANT;
+ case "tip" -> TIP;
+ case "warning" -> WARNING;
+ case "danger", "caution", "error" -> CAUTION;
+ default -> fallback;
+ };
+ }
}
diff --git a/common/src/main/java/rearth/oracle/util/HoverText.java b/common/src/main/java/rearth/oracle/util/HoverText.java
new file mode 100644
index 0000000..97c0037
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/util/HoverText.java
@@ -0,0 +1,77 @@
+package rearth.oracle.util;
+
+import org.commonmark.node.CustomNode;
+import org.commonmark.parser.beta.InlineContentParser;
+import org.commonmark.parser.beta.InlineContentParserFactory;
+import org.commonmark.parser.beta.InlineParserState;
+import org.commonmark.parser.beta.ParsedInline;
+import org.commonmark.parser.beta.Scanner;
+
+import java.util.Set;
+
+/**
+ * Inline node produced by the wiki's hover text syntax: {@code ?[label](hint shown on hover)}.
+ *
+ * Unlike a link destination the hint may contain spaces, so this cannot be expressed with
+ * plain markdown and needs its own inline parser.
+ */
+public class HoverText extends CustomNode {
+
+ private final String label;
+ private final String hint;
+
+ public HoverText(String label, String hint) {
+ this.label = label;
+ this.hint = hint;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public String getHint() {
+ return hint;
+ }
+
+ @Override
+ public String toString() {
+ return "HoverText{label='" + label + "', hint='" + hint + "'}";
+ }
+
+ public static class ParserFactory implements InlineContentParserFactory {
+
+ @Override
+ public Set getTriggerCharacters() {
+ return Set.of('?');
+ }
+
+ @Override
+ public InlineContentParser create() {
+ return new Parser();
+ }
+ }
+
+ private static class Parser implements InlineContentParser {
+
+ @Override
+ public ParsedInline tryParse(InlineParserState state) {
+ var scanner = state.scanner();
+ scanner.next(); // '?'
+ if (!scanner.next('[')) return ParsedInline.none();
+
+ var labelStart = scanner.position();
+ if (scanner.find(']') < 0) return ParsedInline.none();
+ var label = scanner.getSource(labelStart, scanner.position()).getContent();
+ scanner.next(); // ']'
+
+ if (!scanner.next('(')) return ParsedInline.none();
+ var hintStart = scanner.position();
+ if (scanner.find(')') < 0) return ParsedInline.none();
+ var hint = scanner.getSource(hintStart, scanner.position()).getContent();
+ scanner.next(); // ')'
+
+ if (label.isEmpty() || hint.isEmpty()) return ParsedInline.none();
+ return ParsedInline.of(new HoverText(label, hint), scanner.position());
+ }
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/util/MarkdownParser.java b/common/src/main/java/rearth/oracle/util/MarkdownParser.java
index 8efacd0..dc0d253 100644
--- a/common/src/main/java/rearth/oracle/util/MarkdownParser.java
+++ b/common/src/main/java/rearth/oracle/util/MarkdownParser.java
@@ -1,33 +1,37 @@
package rearth.oracle.util;
+import com.mojang.blaze3d.platform.NativeImage;
+import net.minecraft.ChatFormatting;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
-import com.mojang.blaze3d.platform.NativeImage;
-import net.minecraft.world.item.Item;
-import net.minecraft.world.item.ItemStack;
import net.minecraft.core.registries.BuiltInRegistries;
-import net.minecraft.network.chat.ClickEvent;
-import net.minecraft.network.chat.MutableComponent;
-import net.minecraft.network.chat.Style;
-import net.minecraft.network.chat.Component;
import net.minecraft.nbt.StringTag;
-import net.minecraft.ChatFormatting;
+import net.minecraft.network.chat.*;
import net.minecraft.resources.Identifier;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
import org.apache.commons.lang3.StringUtils;
import org.commonmark.Extension;
import org.commonmark.ext.front.matter.YamlFrontMatterExtension;
import org.commonmark.ext.front.matter.YamlFrontMatterVisitor;
+import org.commonmark.ext.gfm.tables.*;
import org.commonmark.node.*;
import org.commonmark.parser.Parser;
import org.jetbrains.annotations.Nullable;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Element;
import rearth.oracle.Oracle;
import rearth.oracle.OracleClient;
import rearth.oracle.ui.OracleScreen;
import rearth.oracle.ui.widgets.*;
+import rearth.oracle.ui.widgets.FlowWidget.HorizontalAlignment;
+import rearth.oracle.ui.widgets.TableWidget.Cell;
+import rearth.oracle.util.MdxAttributes.Match;
import java.io.IOException;
import java.util.*;
import java.util.function.Predicate;
+import java.util.regex.Pattern;
import static rearth.oracle.OracleClient.ROOT_DIR;
@@ -38,10 +42,10 @@
public class MarkdownParser {
private static final Identifier WIKI_LINK_EVENT =
- Identifier.fromNamespaceAndPath(Oracle.MOD_ID, "wiki_link");
+ Identifier.fromNamespaceAndPath(Oracle.MOD_ID, "wiki_link");
private static final String[] removedLines = {"", "", "", "
", "", ""};
- private static final List EXTENSIONS = List.of(YamlFrontMatterExtension.create());
+ private static final List EXTENSIONS = List.of(YamlFrontMatterExtension.create(), TablesExtension.create());
private static final Set> ENABLED_BLOCKS = Set.of(
Heading.class, HtmlBlock.class, ThematicBreak.class,
FencedCodeBlock.class, BlockQuote.class, ListBlock.class
@@ -51,6 +55,7 @@ public class MarkdownParser {
.enabledBlockTypes(ENABLED_BLOCKS)
.extensions(EXTENSIONS)
.customBlockParserFactory(new MdxBlockFactory())
+ .customInlineContentParserFactory(new HoverText.ParserFactory())
.build();
/**
@@ -156,6 +161,31 @@ private void flushBuffer() {
currentIndentation = 0;
}
+ private List collectChildren(Node node) {
+ var previousComponents = this.components;
+ var previousBuffer = this.buffer;
+ var collected = new ArrayList();
+ this.components = collected;
+ this.buffer = Component.empty();
+ visitChildren(node);
+ flushBuffer();
+ this.components = previousComponents;
+ this.buffer = previousBuffer;
+ return collected;
+ }
+
+ private MutableComponent collectInline(Node node, Style baseStyle) {
+ var previousBuffer = this.buffer;
+ var previousStyle = this.currentStyle;
+ this.buffer = Component.empty();
+ this.currentStyle = baseStyle;
+ visitChildren(node);
+ var collected = this.buffer;
+ this.buffer = previousBuffer;
+ this.currentStyle = previousStyle;
+ return collected;
+ }
+
@Override
public void visit(Paragraph paragraph) {
visitChildren(paragraph);
@@ -165,6 +195,7 @@ public void visit(Paragraph paragraph) {
@Override
public void visit(Heading heading) {
buffer = Component.empty();
+ stripHeadingAttributes(heading);
var oldStyle = currentStyle;
currentStyle = currentStyle.withColor(ChatFormatting.GRAY);
visitChildren(heading);
@@ -185,12 +216,25 @@ public void visit(Heading heading) {
@Override
public void visit(FencedCodeBlock codeBlock) {
flushBuffer();
- var panel = FlowWidget.vertical();
- panel.setSurface(WikiSurface.BEDROCK_PANEL_DARK);
- panel.setPadding(Insets.of(6));
- var text = Component.literal(codeBlock.getLiteral()).withStyle(ChatFormatting.GRAY);
- panel.child(new LabelWidget(text));
- components.add(panel);
+ CodeFence fence = CodeFence.parse(codeBlock.getInfo());
+ components.add(new CodeBlockWidget(fence.fileName(), fence.language(), codeBlock.getLiteral()));
+ }
+
+ @Override
+ public void visit(BlockQuote blockQuote) {
+ flushBuffer();
+ GitHubAlert alert = GitHubAlert.consume(blockQuote);
+ List inner = collectChildren(blockQuote);
+
+ if (alert != null) {
+ CalloutWidget callout = new CalloutWidget(alert.variant(), alert.title(), alert.collapsible(), alert.collapsed());
+ for (var c : inner) callout.addBodyChild(c);
+ components.add(callout);
+ } else {
+ var quote = new BlockQuoteWidget();
+ for (var c : inner) quote.child(c);
+ components.add(quote);
+ }
}
@Override
@@ -234,28 +278,132 @@ public void visit(ListItem listItem) {
@Override
public void visit(CustomBlock customBlock) {
- if (customBlock instanceof MdxComponentBlock.CraftingRecipeBlock recipe) {
- components.add(buildRecipe(recipe.slots, recipe.result, recipe.count));
- } else if (customBlock instanceof MdxComponentBlock.AssetBlock image) {
- components.add(buildImage(image.location, image.width, this.wikiId, contentWidthPx));
- } else if (customBlock instanceof MdxComponentBlock.CalloutBlock callout) {
- var oldComponents = this.components;
- var inner = new ArrayList();
- this.components = inner;
- visitChildren(callout);
- flushBuffer();
- this.components = oldComponents;
+ switch (customBlock) {
+ case MdxComponentBlock.CraftingRecipeBlock recipe -> {
+ flushBuffer();
+ components.add(buildRecipe(recipe.slots, recipe.result, recipe.count));
+ }
+ case MdxComponentBlock.AssetBlock asset -> {
+ flushBuffer();
+ components.add(buildImage(asset.location, ImageStyle.ofWidthSource(asset.width), wikiId, contentWidthPx));
+ }
+ case MdxComponentBlock.CalloutBlock callout -> {
+ flushBuffer();
+ var inner = collectChildren(callout);
+ var title = callout.title == null ? null : Component.literal(callout.title);
+ var widget = new CalloutWidget(callout.variant, title, callout.collapsible, callout.collapsed);
+ for (var c : inner) widget.addBodyChild(c);
+ components.add(widget);
+ }
+ case MdxComponentBlock.AudioBlock audio -> {
+ flushBuffer();
+ var widget = buildAudio(audio.src, wikiId);
+ if (widget != null) components.add(widget);
+ }
+ case MdxComponentBlock.VideoEmbedBlock video -> {
+ flushBuffer();
+ if (video.videoId != null && !video.videoId.isBlank()) {
+ components.add(new VideoEmbedWidget(video.videoId, linkHandler));
+ }
+ }
+ case MdxComponentBlock.CodeTabsBlock codeTabs -> {
+ flushBuffer();
+ var widget = buildCodeTabs(codeTabs);
+ if (widget != null) components.add(widget);
+ }
+ case TableBlock table -> buildTable(table);
+ default -> visitChildren(customBlock);
+ }
+ }
- var widget = new CalloutWidget(callout.variant);
- for (var c : inner) widget.addBodyChild(c);
- components.add(widget);
+ @Override
+ public void visit(CustomNode customNode) {
+ if (customNode instanceof HoverText hoverText) {
+ var style = currentStyle
+ .withUnderlined(true)
+ .withHoverEvent(new HoverEvent.ShowText(Component.literal(hoverText.getHint())));
+ buffer.append(Component.literal(hoverText.getLabel()).setStyle(style));
+ return;
+ }
+ super.visit(customNode);
+ }
+
+ @Override
+ public void visit(HtmlBlock htmlBlock) {
+ visitRawHtml(htmlBlock.getLiteral());
+ }
+
+ @Override
+ public void visit(HtmlInline htmlInline) {
+ visitRawHtml(htmlInline.getLiteral());
+ }
+
+ private void visitRawHtml(String html) {
+ org.jsoup.nodes.Document fragment = Jsoup.parseBodyFragment(html);
+
+ Element image = fragment.selectFirst("img");
+ if (image != null && image.hasAttr("src")) {
+ flushBuffer();
+ components.add(buildImage(image.attr("src"), ImageStyle.ofHtml(image), wikiId, contentWidthPx));
}
}
@Override
public void visit(Image image) {
+ ImageStyle style = ImageStyle.consume(image);
+ UIComponent widget = buildImage(image.getDestination(), style, wikiId, contentWidthPx);
flushBuffer();
- components.add(buildImage(image.getDestination(), "60%", wikiId, contentWidthPx));
+
+ String caption = altText(image);
+ if (isStandalone(image) && !caption.isBlank()) {
+ components.add(new FigureWidget(widget, Component.literal(caption), style.alignment()));
+ } else {
+ components.add(widget);
+ }
+ }
+
+ private void buildTable(TableBlock table) {
+ flushBuffer();
+ ArrayList> rows = new ArrayList<>();
+ boolean hasHeader = false;
+
+ for (Node section = table.getFirstChild(); section != null; section = section.getNext()) {
+ boolean header = section instanceof TableHead;
+
+ for (Node row = section.getFirstChild(); row != null; row = row.getNext()) {
+ if (!(row instanceof TableRow)) continue;
+
+ ArrayList cells = new ArrayList<>();
+ for (Node cell = row.getFirstChild(); cell != null; cell = cell.getNext()) {
+ if (!(cell instanceof TableCell tableCell)) continue;
+
+ Style style = header ? Style.EMPTY.withBold(true) : Style.EMPTY;
+ cells.add(new TableWidget.Cell(collectInline(tableCell, style), getCellAlignment(tableCell)));
+ }
+
+ if (header) hasHeader = true;
+ rows.add(cells);
+ }
+ }
+
+ if (!rows.isEmpty()) {
+ components.add(new TableWidget(rows, hasHeader, linkHandler));
+ }
+ }
+
+ @Nullable
+ private UIComponent buildCodeTabs(Node container) {
+ var tabs = new ArrayList();
+ for (var child = container.getFirstChild(); child != null; child = child.getNext()) {
+ if (!(child instanceof FencedCodeBlock code)) continue;
+ var fence = CodeFence.parse(code.getInfo());
+ var title = fence.tabTitle() != null ? fence.tabTitle()
+ : fence.fileName() != null ? fence.fileName()
+ : fence.language() != null ? fence.language()
+ : "Tab " + (tabs.size() + 1);
+ tabs.add(new CodeTabsWidget.Tab(title, code.getLiteral()));
+ }
+ return tabs.isEmpty() ? null : new CodeTabsWidget(tabs);
}
@Override
@@ -283,8 +431,8 @@ public void visit(Emphasis e) {
public void visit(Link link) {
var old = currentStyle;
var clickEvent = new ClickEvent.Custom(
- WIKI_LINK_EVENT,
- Optional.of(StringTag.valueOf(link.getDestination()))
+ WIKI_LINK_EVENT,
+ Optional.of(StringTag.valueOf(link.getDestination()))
);
currentStyle = currentStyle.withColor(ChatFormatting.BLUE).withUnderlined(true).withClickEvent(clickEvent);
@@ -314,9 +462,166 @@ public void visit(HardLineBreak n) {
}
}
+ private static void stripHeadingAttributes(Heading heading) {
+ Node last = heading.getLastChild();
+ if (!(last instanceof org.commonmark.node.Text text)) return;
+
+ Match match = MdxAttributes.matchTrailing(text.getLiteral());
+ if (match == null) return;
+
+ text.setLiteral(match.remainder());
+ }
+
+ private static boolean isStandalone(Image image) {
+ if (!(image.getParent() instanceof Paragraph paragraph)) return false;
+
+ for (var sibling = paragraph.getFirstChild(); sibling != null; sibling = sibling.getNext()) {
+ if (sibling == image || sibling instanceof Text text && text.getLiteral().isBlank() || sibling instanceof SoftLineBreak)
+ continue;
+ return false;
+ }
+
+ return true;
+ }
+
+ private static String altText(Image image) {
+ StringBuilder alt = new StringBuilder();
+ for (var child = image.getFirstChild(); child != null; child = child.getNext()) {
+ if (child instanceof org.commonmark.node.Text text) {
+ alt.append(text.getLiteral());
+ }
+ }
+ return alt.toString().trim();
+ }
+
+ public record CodeFence(@Nullable String language, @Nullable String fileName, @Nullable String tabTitle) {
+ private static final String TABS_MARKER = "!!tabs";
+
+ public static CodeFence parse(@Nullable String info) {
+ if (info == null || info.isBlank()) return new CodeFence(null, null, null);
+ String[] tokens = info.trim().split("\\s+");
+ String language = tokens[0].isBlank() ? null : tokens[0];
+
+ String fileName = null;
+ String tabTitle = null;
+ for (int i = 1; i < tokens.length; i++) {
+ if (TABS_MARKER.equals(tokens[i])) {
+ if (i + 1 < tokens.length) tabTitle = String.join(" ", Arrays.copyOfRange(tokens, i + 1, tokens.length));
+ break;
+ }
+ fileName = fileName == null ? tokens[i] : fileName + " " + tokens[i];
+ }
+
+ return new CodeFence(language, fileName, tabTitle);
+ }
+ }
+
+ public record GitHubAlert(CalloutVariant variant, @Nullable Component title, boolean collapsible, boolean collapsed) {
+ private static final Pattern HEADER = Pattern.compile("^\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)]([+-]?)\\s*(.*)$");
+
+ @Nullable
+ public static GitHubAlert consume(BlockQuote blockQuote) {
+ if (!(blockQuote.getFirstChild() instanceof Paragraph paragraph)) return null;
+
+ // collect the first line's plain text; formatting inside the header is not supported
+ var line = new StringBuilder();
+ var consumed = new ArrayList();
+ Node lineBreak = null;
+ for (var child = paragraph.getFirstChild(); child != null; child = child.getNext()) {
+ if (child instanceof org.commonmark.node.Text text) {
+ line.append(text.getLiteral());
+ consumed.add(child);
+ } else if (child instanceof SoftLineBreak || child instanceof HardLineBreak) {
+ lineBreak = child;
+ break;
+ } else {
+ break;
+ }
+ }
+
+ var matcher = HEADER.matcher(line.toString().trim());
+ if (!matcher.matches()) return null;
+
+ for (var node : consumed) node.unlink();
+ if (lineBreak != null) lineBreak.unlink();
+ if (paragraph.getFirstChild() == null) paragraph.unlink();
+
+ var variant = CalloutVariant.byName(matcher.group(1), CalloutVariant.NOTE);
+ var marker = matcher.group(2);
+ var title = matcher.group(3).isBlank() ? null : Component.literal(matcher.group(3).trim());
+ boolean collapsed = "-".equals(marker);
+ boolean collapsible = collapsed || "+".equals(marker);
+ return new GitHubAlert(variant, title, collapsible, collapsed);
+ }
+ }
+
+ public record ImageStyle(@Nullable Float widthRatio, @Nullable Integer width, @Nullable Integer height,
+ boolean item, FlowWidget.HorizontalAlignment alignment
+ ) {
+ private static final int ITEM_SIZE = 32;
+
+ public static final ImageStyle DEFAULT = new ImageStyle(null, null, null, false, FlowWidget.HorizontalAlignment.CENTER);
+
+ private static ImageStyle consume(Image image) {
+ if (!(image.getNext() instanceof org.commonmark.node.Text text)) return DEFAULT;
+
+ Match match = MdxAttributes.matchLeading(text.getLiteral());
+ if (match == null) return DEFAULT;
+
+ text.setLiteral(match.remainder());
+
+ return of(match.attributes());
+ }
+
+ private static ImageStyle of(MdxAttributes attributes) {
+ HorizontalAlignment alignment = attributes.has("center") || attributes.has("right")
+ ? FlowWidget.HorizontalAlignment.CENTER
+ : FlowWidget.HorizontalAlignment.LEFT;
+ String rawWidth = attributes.get("width");
+ Float ratio = rawWidth != null && rawWidth.endsWith("%") ? convertImageWidth(rawWidth) : null;
+ return new ImageStyle(ratio, attributes.getPixels("width"), attributes.getPixels("height"), attributes.has("item"), alignment);
+ }
+
+ private static ImageStyle ofHtml(Element element) {
+ Map attributes = new HashMap<>();
+ if (element.hasAttr("width")) {
+ attributes.put("width", element.attr("width"));
+ }
+ if (element.hasAttr("height")) {
+ attributes.put("height", element.attr("height"));
+ }
+
+ Set flags = new HashSet<>();
+ for (var name : element.attr("class").split("\\s+")) {
+ if (!name.isBlank()) flags.add(name);
+ }
+
+ if (element.hasAttr("align")) {
+ flags.add(element.attr("align").toLowerCase(Locale.ROOT));
+ }
+
+ return of(new MdxAttributes(attributes, flags));
+ }
+
+ private static ImageStyle ofWidthSource(@Nullable String widthSource) {
+ float ratio = convertImageWidth(widthSource);
+ return new ImageStyle(ratio > 0 ? ratio : null, null, null, false, FlowWidget.HorizontalAlignment.CENTER);
+ }
+
+ private int resolveWidth(int budget, float defaultRatio) {
+ if (item) return ITEM_SIZE;
+ if (width != null && width > 0) return Math.min(width, budget);
+ float ratio = widthRatio != null && widthRatio > 0 ? widthRatio : defaultRatio;
+ return Math.max(16, (int) (budget * ratio));
+ }
+ }
+
// ---------------------------------------------------------------- helpers
public static MutableComponent getLinkText(String link, String activeWikiId, Identifier sourceEntryPath) {
+ int anchor = link.indexOf('#');
+ if (anchor > 0) link = link.substring(0, anchor);
+
if (link.startsWith("@")) {
Identifier id = Identifier.tryParse(link.substring(1));
if (id != null && id.getNamespace().equals(Identifier.DEFAULT_NAMESPACE)) {
@@ -387,6 +692,18 @@ private static UIComponent buildTitlePanel(Predicate linkHandler, Frontm
);
}
+ private static FlowWidget.HorizontalAlignment getCellAlignment(TableCell cell) {
+ var alignment = cell.getAlignment();
+ if (alignment == null) {
+ return FlowWidget.HorizontalAlignment.LEFT;
+ }
+ return switch (alignment) {
+ case LEFT -> FlowWidget.HorizontalAlignment.LEFT;
+ case CENTER -> FlowWidget.HorizontalAlignment.CENTER;
+ case RIGHT -> FlowWidget.HorizontalAlignment.RIGHT;
+ };
+ }
+
private static ItemStack getIconStack(String iconId) {
if (Identifier.tryParse(iconId) != null && BuiltInRegistries.ITEM.containsKey(Identifier.parse(iconId))) {
return new ItemStack(BuiltInRegistries.ITEM.getValue(Identifier.parse(iconId)));
@@ -646,20 +963,35 @@ public static UIComponent buildRecipe(List inputs, String resultId, int
return panel;
}
- public static UIComponent buildImage(String location, String widthSource, String wikiId, int contentWidthPx) {
- var widthRatio = convertImageWidth(widthSource);
- if (widthRatio <= 0) widthRatio = 0.5f;
+ public static Identifier resolveAssetPath(String location, String wikiId, String defaultExtension) {
+ if (location.startsWith("@")) location = location.substring(1);
+
+ var assetsRoot = OracleClient.getWikiFormat(wikiId).getAssetsRoot();
+ var parts = location.split(":", 2);
+ var assetModId = parts.length > 1 ? parts[0] : wikiId;
+ var assetPath = parts.length > 1 ? parts[1] : location;
+ var extension = assetPath.contains(".") ? "" : defaultExtension;
+
+ return Identifier.fromNamespaceAndPath(
+ Oracle.MOD_ID,
+ ROOT_DIR + "/" + wikiId + assetsRoot + "/" + assetModId + "/" + assetPath + extension
+ );
+ }
+
+ public static UIComponent buildImage(String location, ImageStyle style, String wikiId, int contentWidthPx) {
+ if (location == null || location.isBlank()) {
+ return new LabelWidget(Component.literal("Missing image location").withStyle(ChatFormatting.RED));
+ }
if (location.startsWith("@")) location = location.substring(1);
// available pixel budget after scrollbar gutter + a tiny breathing margin
var budget = Math.max(16, contentWidthPx - 12);
// case 1: ingame item → render as ItemWidget
- var itemIdCandidate = Identifier.parse(location);
- if (BuiltInRegistries.ITEM.containsKey(itemIdCandidate)) {
- // items default to ~10% of content width when no width is specified
- if (widthRatio == 0.5f) widthRatio = 0.1f;
- int displaySize = Math.max(16, (int) (budget * widthRatio));
+ var itemIdCandidate = Identifier.tryParse(location);
+ if (itemIdCandidate != null && BuiltInRegistries.ITEM.containsKey(itemIdCandidate)) {
+ // items default to ~10% of content width when no size is specified
+ int displaySize = style.resolveWidth(budget, 0.1f);
var itemWidget = new ItemWidget(new ItemStack(BuiltInRegistries.ITEM.getValue(itemIdCandidate)));
itemWidget.size(displaySize, displaySize);
itemWidget.setHideItemDecorations(true);
@@ -667,14 +999,7 @@ public static UIComponent buildImage(String location, String widthSource, String
}
// case 2: texture path
- var assetsRoot = OracleClient.getWikiFormat(wikiId).getAssetsRoot();
- Identifier searchPath;
- var parts = location.split(":", 2);
- var imageModId = parts.length > 0 ? parts[0] : wikiId;
- var imagePath = parts.length > 1 ? parts[1] : location;
- var extension = imagePath.contains(".") ? "" : ".png";
- searchPath = Identifier.fromNamespaceAndPath(Oracle.MOD_ID, ROOT_DIR + "/" + wikiId + assetsRoot + "/" + imageModId + "/" + imagePath + extension);
-
+ var searchPath = resolveAssetPath(location, wikiId, ".png");
var rm = Minecraft.getInstance().getResourceManager();
var resource = rm.getResource(searchPath);
if (resource.isEmpty()) {
@@ -684,12 +1009,15 @@ public static UIComponent buildImage(String location, String widthSource, String
var image = NativeImage.read(resource.get().open());
int srcW = image.getWidth();
int srcH = image.getHeight();
- int displayW = Math.max(16, (int) (budget * widthRatio));
- int displayH = (int) (displayW * (srcH / (float) srcW));
+ int displayW = style.resolveWidth(budget, 0.5f);
+ int displayH = style.item() ? displayW
+ : style.height() != null && style.height() > 0 ? style.height()
+ : (int) (displayW * (srcH / (float) srcW));
+ var alignment = style.alignment();
var widget = new TextureWidget(searchPath, srcW, srcH) {
@Override
- public @Nullable FlowWidget.HorizontalAlignment getOverrideAlignment() {
- return FlowWidget.HorizontalAlignment.CENTER;
+ public FlowWidget.HorizontalAlignment getOverrideAlignment() {
+ return alignment;
}
};
widget.region(0, 0, srcW, srcH);
@@ -700,6 +1028,16 @@ public static UIComponent buildImage(String location, String widthSource, String
}
}
+ @Nullable
+ public static UIComponent buildAudio(@Nullable String source, String wikiId) {
+ if (source == null || source.isBlank()) return null;
+
+ Identifier path = resolveAssetPath(source, wikiId, ".ogg");
+ String[] segments = path.getPath().split("/");
+
+ return new AudioWidget(path, segments[segments.length - 1]);
+ }
+
public static Frontmatter parseFrontmatter(String markdown) {
var document = PARSER.parse(markdown);
var yamlVisitor = new YamlFrontMatterVisitor();
diff --git a/common/src/main/java/rearth/oracle/util/MdxAttributes.java b/common/src/main/java/rearth/oracle/util/MdxAttributes.java
new file mode 100644
index 0000000..4629fe7
--- /dev/null
+++ b/common/src/main/java/rearth/oracle/util/MdxAttributes.java
@@ -0,0 +1,108 @@
+package rearth.oracle.util;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.*;
+
+public record MdxAttributes(Map values, Set flags) {
+ public static final MdxAttributes EMPTY = new MdxAttributes(Map.of(), Set.of());
+
+ public record Match(MdxAttributes attributes, String remainder) {
+ }
+
+ public static MdxAttributes parse(String content) {
+ if (content == null || content.isBlank()) return EMPTY;
+
+ Map values = new HashMap<>();
+ Set flags = new HashSet<>();
+
+ for (var token : content.trim().split("\\s+")) {
+ if (token.isEmpty()) continue;
+
+ int separator = token.indexOf('=');
+ if (separator > 0) {
+ String key = token.substring(0, separator).trim().toLowerCase(Locale.ROOT);
+ String value = unwrap(token.substring(separator + 1).trim());
+ if (!key.isEmpty()) {
+ values.put(key, value);
+ }
+ } else if (token.startsWith("#") && token.length() > 1) {
+ values.put("id", token.substring(1));
+ } else {
+ flags.add(token.toLowerCase(Locale.ROOT));
+ }
+ }
+
+ return new MdxAttributes(values, flags);
+ }
+
+ @Nullable
+ public static Match matchLeading(String text) {
+ if (text == null || text.isEmpty() || text.charAt(0) != '{') return null;
+
+ int end = text.indexOf('}');
+ if (end < 0) return null;
+
+ return new Match(parse(text.substring(1, end)), text.substring(end + 1));
+ }
+
+ @Nullable
+ public static Match matchTrailing(@Nullable String text) {
+ if (text == null) return null;
+
+ String trimmed = text.stripTrailing();
+ if (!trimmed.endsWith("}")) return null;
+
+ int start = trimmed.lastIndexOf('{');
+ if (start < 0) return null;
+
+ String inside = trimmed.substring(start + 1, trimmed.length() - 1);
+ if (inside.indexOf('{') >= 0 || inside.indexOf('}') >= 0) return null;
+
+ return new Match(parse(inside), trimmed.substring(0, start).stripTrailing());
+ }
+
+ private static String unwrap(String value) {
+ if (value.length() >= 2) {
+ char first = value.charAt(0);
+ char last = value.charAt(value.length() - 1);
+ if (first == '"' && last == '"' || first == '\'' && last == '\'') {
+ return value.substring(1, value.length() - 1);
+ }
+ }
+
+ if (value.length() >= 2 && value.charAt(0) == '{' && value.charAt(value.length() - 1) == '}') {
+ return value.substring(1, value.length() - 1);
+ }
+
+ return value;
+ }
+
+ public boolean has(String flag) {
+ return flags.contains(flag);
+ }
+
+ @Nullable
+ public String get(String key) {
+ return values.get(key);
+ }
+
+ @Nullable
+ public Integer getPixels(String key) {
+ var raw = values.get(key);
+ if (raw == null) return null;
+
+ var cleaned = raw.trim().toLowerCase(Locale.ROOT);
+ if (cleaned.endsWith("px")) cleaned = cleaned.substring(0, cleaned.length() - 2);
+
+ try {
+ return Integer.parseInt(cleaned.trim());
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+
+ public boolean isEmpty() {
+ return values.isEmpty() && flags.isEmpty();
+ }
+}
diff --git a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java
index 62a6e17..0d45968 100644
--- a/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java
+++ b/common/src/main/java/rearth/oracle/util/MdxBlockFactory.java
@@ -6,11 +6,11 @@
import org.commonmark.parser.block.ParserState;
public class MdxBlockFactory extends AbstractBlockParserFactory {
-
+
@Override
public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {
String line = state.getLine().getContent().toString().trim();
-
+
// leaf blocks
if (line.startsWith(" slots = new ArrayList<>();
public String result;
public int count = 1;
-
+
@Override
void parseContent() {
// regex is safer for the MDX array syntax than jsoup: slots={[ ... ]}
// matches content inside slots={[ ... ]}
var slotPattern = Pattern.compile("slots=\\{\\[(.*?)]}");
var slotMatcher = slotPattern.matcher(rawContent.replace("\n", " "));
-
+
if (slotMatcher.find()) {
var arrayContent = slotMatcher.group(1);
var items = arrayContent.split(",");
@@ -47,12 +48,12 @@ void parseContent() {
slots.add(item.trim().replace("'", "").replace("\"", ""));
}
}
-
+
// use jsoup for simple attributes like result="mod:item"
// strip the slots part to not confuse Jsoup
var safeHtml = rawContent.replaceAll("slots=\\{\\[.*?]}", "");
var el = Jsoup.parseBodyFragment(safeHtml).selectFirst("CraftingRecipe");
-
+
if (el != null) {
this.result = el.attr("result");
var countStr = el.attr("count").replaceAll("[{}]", "");
@@ -62,54 +63,60 @@ void parseContent() {
}
}
}
-
+
@Override
public String toString() {
return "CraftingRecipeBlock{" +
- "slots=" + slots +
- ", result='" + result + '\'' +
- ", count=" + count +
- ", rawContent='" + rawContent + '\'' +
- '}';
+ "slots=" + slots +
+ ", result='" + result + '\'' +
+ ", count=" + count +
+ ", rawContent='" + rawContent + '\'' +
+ '}';
}
}
-
+
public static class CalloutBlock extends MdxComponentBlock {
- public CalloutVariant variant = CalloutVariant.DEFAULT;
-
+ public CalloutVariant variant = CalloutVariant.NOTE;
+ @Nullable
+ public String title;
+ public boolean collapsible;
+ public boolean collapsed;
+
@Override
void parseContent() {
- var el = Jsoup.parseBodyFragment(rawContent).selectFirst("Callout");
- if (el != null) {
- if (el.hasAttr("variant")) {
- String name = el.attr("variant");
- try {
- this.variant = CalloutVariant.valueOf(name.toUpperCase(Locale.ROOT));
- } catch (IllegalArgumentException e) {
- LOGGER.error("Unknown callout variant: '{}'", name, e);
- }
- }
+ var el = element("Callout");
+ if (el == null) return;
+
+ var type = el.hasAttr("type") ? el.attr("type") : el.attr("variant");
+ this.variant = CalloutVariant.byName(type, CalloutVariant.NOTE);
+ if (el.hasAttr("title")) {
+ this.title = el.attr("title");
}
+ this.collapsed = el.hasAttr("collapsed");
+ this.collapsible = this.collapsed || el.hasAttr("collapsible");
}
-
+
@Override
public String toString() {
return "CalloutBlock{" +
- "variant='" + variant + '\'' +
- ", rawContent='" + rawContent + '\'' +
- '}';
+ "variant='" + variant + '\'' +
+ ", title='" + title + '\'' +
+ ", collapsible=" + collapsible +
+ ", collapsed=" + collapsed +
+ ", rawContent='" + rawContent + '\'' +
+ '}';
}
}
-
+
public static class AssetBlock extends MdxComponentBlock {
private final String tagName;
public String location;
public String width = "50%";
-
+
public AssetBlock(String tagName) {
this.tagName = tagName;
}
-
+
@Override
void parseContent() {
var el = Jsoup.parseBodyFragment(rawContent).selectFirst(tagName);
@@ -120,14 +127,61 @@ void parseContent() {
}
}
}
-
+
@Override
public String toString() {
return "AssetBlock{" +
- "location='" + location + '\'' +
- ", width='" + width + '\'' +
- ", rawContent='" + rawContent + '\'' +
- '}';
+ "location='" + location + '\'' +
+ ", width='" + width + '\'' +
+ ", rawContent='" + rawContent + '\'' +
+ '}';
+ }
+ }
+
+ public static class VideoEmbedBlock extends MdxComponentBlock {
+ public String videoId;
+
+ @Override
+ void parseContent() {
+ var el = element("VideoEmbed");
+ if (el != null) this.videoId = el.attr("id");
+ }
+
+ @Override
+ public String toString() {
+ return "VideoEmbedBlock{videoId='" + videoId + "'}";
+ }
+ }
+
+ public static class AudioBlock extends MdxComponentBlock {
+ public String src;
+
+ @Override
+ void parseContent() {
+ var el = element("Audio");
+ if (el == null) el = element("audio");
+ if (el != null) this.src = el.attr("src");
+ }
+
+ @Override
+ public String toString() {
+ return "AudioBlock{src='" + src + "'}";
+ }
+ }
+
+ public static class CodeTabsBlock extends MdxComponentBlock {
+ @Override
+ void parseContent() {
}
+
+ @Override
+ public String toString() {
+ return "CodeTabsBlock{}";
+ }
+ }
+
+ @Nullable
+ protected Element element(String tagName) {
+ return Jsoup.parseBodyFragment(rawContent).selectFirst(tagName);
}
}
diff --git a/common/src/main/resources/assets/oracle_index/lang/en_us.json b/common/src/main/resources/assets/oracle_index/lang/en_us.json
index 4d9a960..4a4179c 100644
--- a/common/src/main/resources/assets/oracle_index/lang/en_us.json
+++ b/common/src/main/resources/assets/oracle_index/lang/en_us.json
@@ -12,8 +12,10 @@
"oracle_index.button.docs": "Docs",
"oracle_index.button.content": "Content",
"oracle_index.tooltip.docs": "Hold [ALT] for Documentation",
- "oracle_index.callout.default": "Note",
- "oracle_index.callout.info": "Info",
+ "oracle_index.callout.note": "Note",
+ "oracle_index.callout.tip": "Tip",
+ "oracle_index.callout.important": "Important",
"oracle_index.callout.warning": "Warning",
- "oracle_index.callout.danger": "Danger"
+ "oracle_index.callout.caution": "Caution",
+ "oracle_index.video.watch": "Watch on YouTube"
}
diff --git a/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_important.png b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_important.png
new file mode 100644
index 0000000..2a0f78b
Binary files /dev/null and b/common/src/main/resources/assets/oracle_index/textures/gui/bedrock_panel_important.png differ
diff --git a/common/src/main/resources/oracle_index.accesswidener b/common/src/main/resources/oracle_index.accesswidener
index 8fa4e7f..c422024 100644
--- a/common/src/main/resources/oracle_index.accesswidener
+++ b/common/src/main/resources/oracle_index.accesswidener
@@ -1,3 +1,6 @@
accessWidener v2 official
accessible field net/minecraft/client/multiplayer/ClientAdvancements progress Ljava/util/Map;
+accessible field net/minecraft/client/sounds/SoundManager soundCache Ljava/util/Map;
+accessible field net/minecraft/client/sounds/SoundManager soundEngine Lnet/minecraft/client/sounds/SoundEngine;
+accessible field net/minecraft/client/sounds/SoundEngine instanceToChannel Ljava/util/Map;
diff --git a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx
index 1a1f8db..8a86805 100644
--- a/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx
+++ b/common/src/testmod/resources/assets/oracle_index/books/oracle-index-test/docs/introduction.mdx
@@ -10,6 +10,73 @@ Green Apple by ref default title: [](+my_green_apple).
Vanilla link: [](@minecraft:apple)
+## Tables
+
+| Some table | One | Two | Three | Four |
+|-------------|-----|-----|-------|------|
+| First value | Yes | ❌ | 1 | 4 |
+| Lorem ipsum | No | ✅ | 2 | 5 |
+| Foo bar | Yes | 🟢 | 3 | 6 |
+
+## Markdown sugar
+
+### Block quote
+
+> Example block quote text.
+> Lorem ipsum dolor sit amet.
+
+### Assets
+
+
+
+
+
+### GitHub-style Alert
+
+> [!NOTE]
+> Useful information that users should know, even when skimming content.
+
+> [!TIP]
+> Helpful advice for doing things better or more easily.
+
+> [!IMPORTANT]
+> Key information users need to know to achieve their goal.
+
+> [!WARNING]
+> Urgent info that needs immediate user attention to avoid problems.
+
+> [!CAUTION]
+> Advises about risks or negative outcomes of certain actions.
+
+#### Collapsible
+
+> [!TIP] Pro tip
+> A callout with a custom title.
+
+> [!WARNING]+
+> A collapsible callout, expanded by default.
+
+> [!CAUTION]- At your own risk
+> A collapsible callout with a custom title, collapsed by default.
+
+### Hover text
+
+A pickaxe is required to ?[mine](break while also dropping an item) this block.
+
+### Figures
+
+
+
+### Image attributes
+
+{center width=100}
+
+{item}
+
+## Heading attributes {clear #attrs}
+
+{right}
+
## Components
### `Asset`
@@ -36,6 +103,15 @@ Vanilla link: [](@minecraft:apple)
}
```
+
+```
+function noLanguage(ipsum, dolor = 1) {
+ const sit = ipsum == null ? 0 : ipsum.sit
+ dolor = sit - amet(dolor)
+ return sit ? consectetur(ipsum) : []
+}
+```
+
### `Callout`
@@ -46,6 +122,10 @@ Vanilla link: [](@minecraft:apple)
This is a sample info callout
+
+ This is a sample important callout
+
+
This is a sample warning callout
@@ -54,6 +134,16 @@ Vanilla link: [](@minecraft:apple)
This is a sample danger callout
+#### Collapsible
+
+
+ This is a sample collapsible info callout
+
+
+
+ This is a sample collapsed info callout
+
+
### `CodeTabs`
diff --git a/fabric/build.gradle b/fabric/build.gradle
index 28eb276..24001c9 100644
--- a/fabric/build.gradle
+++ b/fabric/build.gradle
@@ -52,6 +52,8 @@ dependencies {
include 'org.commonmark:commonmark:0.27.1'
implementation 'org.commonmark:commonmark-ext-yaml-front-matter:0.27.1'
include 'org.commonmark:commonmark-ext-yaml-front-matter:0.27.1'
+ implementation 'org.commonmark:commonmark-ext-gfm-tables:0.27.1'
+ include 'org.commonmark:commonmark-ext-gfm-tables:0.27.1'
// exp4j for math expressions in search bar
implementation 'net.objecthunter:exp4j:0.4.8'
include 'net.objecthunter:exp4j:0.4.8'
diff --git a/neoforge/build.gradle b/neoforge/build.gradle
index 3f7bd92..ad8e3a9 100644
--- a/neoforge/build.gradle
+++ b/neoforge/build.gradle
@@ -80,6 +80,7 @@ dependencies {
// commonmark for markdown parsing
forgeRuntimeLibrary(include(api('org.commonmark:commonmark:0.27.1')))
forgeRuntimeLibrary(include(api('org.commonmark:commonmark-ext-yaml-front-matter:0.27.1')))
+ forgeRuntimeLibrary(include(api('org.commonmark:commonmark-ext-gfm-tables:0.27.1')))
// just for testing mffs wiki ingame
// modImplementation "curse.maven:mffs-238546:7370783"
|