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 + +![](@oracle_index_test:folder_layout) + + + +### 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 + +![Folder layout](@oracle_index_test:folder_layout) + +### Image attributes + +![Folder layout](@oracle_index_test:folder_layout){center width=100} + +![Folder layout](@oracle_index_test:folder_layout){item} + +## Heading attributes {clear #attrs} + +![Folder layout](@oracle_index_test:folder_layout){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"