diff --git a/src/main/java/com/hfstudio/guidenh/ClientProxy.java b/src/main/java/com/hfstudio/guidenh/ClientProxy.java index 2807f52f..a382bf6e 100644 --- a/src/main/java/com/hfstudio/guidenh/ClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/ClientProxy.java @@ -7,6 +7,7 @@ import com.hfstudio.guidenh.bridge.GuideNhRuntimeBridge; import com.hfstudio.guidenh.bridge.GuideNhRuntimeBridgeSettings; +import com.hfstudio.guidenh.client.GuideNhClientTaskScheduler; import com.hfstudio.guidenh.client.RegionWandRenderer; import com.hfstudio.guidenh.client.command.GuideNhClientBridgeController; import com.hfstudio.guidenh.client.command.GuideNhClientCommand; @@ -112,6 +113,7 @@ public static CompileWorker getWorker() { @Override public void preInit(FMLPreInitializationEvent event) { super.preInit(event); + GuideNhClientTaskScheduler.initialize(); GuidebookLevel.setPreviewWorldFactory(GuidebookFakeWorld::new); GuideNhClientIntegrationBootstrap.preInitClient(); GuideME.initClientProxy(); diff --git a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java index 03df98f5..ee44e871 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/preview/ItemPreviewService.java @@ -23,6 +23,7 @@ import org.lwjgl.opengl.GL11; import com.hfstudio.guidenh.bridge.protocol.BridgeProtocolLimits; +import com.hfstudio.guidenh.client.GuideNhClientTaskScheduler; import com.hfstudio.guidenh.guide.compiler.IdUtils; import com.hfstudio.guidenh.guide.document.interaction.ItemTooltip; import com.hfstudio.guidenh.guide.internal.tooltip.GuideItemTooltipLines; @@ -98,7 +99,7 @@ private ItemPreviewPayload renderOnClientThread(ItemPreviewCacheKey cacheKey, It } CompletableFuture future = new CompletableFuture<>(); - minecraft.func_152344_a(() -> { + GuideNhClientTaskScheduler.execute(() -> { try { future.complete(createPayload(cacheKey, stack, Minecraft.getMinecraft())); } catch (Throwable error) { diff --git a/src/main/java/com/hfstudio/guidenh/client/GuideNhClientTaskScheduler.java b/src/main/java/com/hfstudio/guidenh/client/GuideNhClientTaskScheduler.java new file mode 100644 index 00000000..4f0a0c6d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/client/GuideNhClientTaskScheduler.java @@ -0,0 +1,67 @@ +package com.hfstudio.guidenh.client; + +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import net.minecraft.client.Minecraft; + +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; + +import cpw.mods.fml.common.FMLCommonHandler; +import cpw.mods.fml.common.eventhandler.SubscribeEvent; +import cpw.mods.fml.common.gameevent.TickEvent; +import cpw.mods.fml.relauncher.Side; +import cpw.mods.fml.relauncher.SideOnly; + +@SideOnly(Side.CLIENT) +public class GuideNhClientTaskScheduler { + + private static final Queue PENDING_TASKS = new ConcurrentLinkedQueue<>(); + private static final Runnable TICK_BOUNDARY = () -> {}; + private static boolean initialized; + + public static void initialize() { + if (initialized) { + return; + } + FMLCommonHandler.instance() + .bus() + .register(new GuideNhClientTaskScheduler()); + initialized = true; + } + + public static boolean isOnClientThread() { + Minecraft minecraft = Minecraft.getMinecraft(); + return minecraft != null && minecraft.func_152345_ab(); + } + + public static void execute(Runnable task) { + Objects.requireNonNull(task, "task"); + if (isOnClientThread()) { + runTask(task); + return; + } + PENDING_TASKS.add(task); + } + + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + PENDING_TASKS.add(TICK_BOUNDARY); + Runnable task; + while ((task = PENDING_TASKS.poll()) != null && task != TICK_BOUNDARY) { + runTask(task); + } + } + + private static void runTask(Runnable task) { + try { + task.run(); + } catch (Throwable error) { + GuideDebugLog.error("Client task execution failed", error); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideItemReferenceResolver.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideItemReferenceResolver.java index e3a5ffae..ff9c9780 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideItemReferenceResolver.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideItemReferenceResolver.java @@ -104,19 +104,23 @@ public static ItemStack resolveOreDictionaryStack(@Nullable String oreName) { } List oreStacks = OreDictionary.getOres(trimmedOreName); - if (oreStacks == null || oreStacks.isEmpty()) { + if (oreStacks.isEmpty()) { return null; } - ItemStack firstMatch = oreStacks.getFirst(); - if (firstMatch == null || firstMatch.getItem() == null) { - return null; + for (ItemStack stack : oreStacks) { + if (stack == null || stack.getItem() == null) { + continue; + } + + ItemStack copiedStack = stack.copy(); + ItemStack normalizedStack = GuideNhIntegrationRegistry.global() + .normalizeItemStack(copiedStack); + + return normalizedStack != null && normalizedStack.getItem() != null ? normalizedStack : copiedStack; } - ItemStack copiedStack = firstMatch.copy(); - ItemStack normalizedStack = GuideNhIntegrationRegistry.global() - .normalizeItemStack(copiedStack); - return normalizedStack != null && normalizedStack.getItem() != null ? normalizedStack : copiedStack; + return null; } @Nullable diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/IdUtils.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/IdUtils.java index bb20f028..aba622ad 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/IdUtils.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/IdUtils.java @@ -106,7 +106,7 @@ public static ParsedItemRef parseItemRef(String idText, String defaultNamespace) nbt = tc; } } catch (Throwable t) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [IdUtils] Failed to parse SNBT tail '{}' for id '{}'; ignoring NBT", snbt, idText, diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java index 23694a7d..df51d16d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java @@ -993,9 +993,9 @@ public LytFlowContent createErrorFlowContent(String text, UnistNode child) { span.appendText(tildes + "^"); span.appendBreak(); - GuideDebugLog.warnAlways("[GuideNH] [PageCompiler] {}\n{}\n{}\n", text, line, tildes + "^"); + GuideDebugLog.warn("[GuideNH] [PageCompiler] {}\n{}\n{}\n", text, line, tildes + "^"); } else { - GuideDebugLog.warnAlways("[GuideNH] [PageCompiler] {}\n", text); + GuideDebugLog.warn("[GuideNH] [PageCompiler] {}\n", text); } return span; diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java index 16381ece..06b6bada 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java @@ -72,19 +72,21 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl } private void compileDirectiveBody(PageCompiler compiler, BlockquoteDirective directive, LytBlockContainer parent) { + MdxJsxFlowElement firstParagraph = directive.firstParagraph() instanceof MdxJsxFlowElement paragraph ? paragraph + : null; // When there's a remainingText override and the first paragraph is still present // at the head of the children list, replace its leading text. // Otherwise — just compile children normally. if (!directive.children() - .isEmpty() && directive.firstParagraph() != null + .isEmpty() && firstParagraph != null && directive.children() - .getFirst() == directive.firstParagraph() + .getFirst() == firstParagraph && directive.remainingText() != null && !directive.remainingText() .isEmpty()) { // Strip directive prefix from the first paragraph's leading text - stripLeadingText(directive.firstParagraph(), directive.remainingText()); - compiler.compileBlockContext(Collections.singletonList(directive.firstParagraph()), parent); + stripLeadingText(firstParagraph, directive.remainingText()); + compiler.compileBlockContext(Collections.singletonList(firstParagraph), parent); for (int i = 1; i < directive.children() .size(); i++) { compiler.compileBlockContext( diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java index 705a9734..9ee2c4f9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlockToolbar.java @@ -25,7 +25,7 @@ public class LytCodeBlockToolbar extends LytBox implements InteractiveElement { - static final GuiSprite COPY_SPRITE = new GuiSprite( + public static final GuiSprite COPY_SPRITE = new GuiSprite( GuideIconButton.TEX, 0, 48, @@ -33,6 +33,14 @@ public class LytCodeBlockToolbar extends LytBox implements InteractiveElement { 16, GuideIconButton.TEXTURE_SIZE, GuideIconButton.TEXTURE_SIZE); + public static final GuiSprite RESET_VIEW_SPRITE = new GuiSprite( + GuideIconButton.TEX, + 0, + 32, + 16, + 16, + GuideIconButton.TEXTURE_SIZE, + GuideIconButton.TEXTURE_SIZE); private static final long COPY_TOOLTIP_RESET_DELAY_MILLIS = 1500L; private static final int TEXT_CENTERING_OFFSET_Y = 1; private static final CodeHighlightTheme CODE_THEME = CodeHighlightTheme.GITHUB_DARK_DEFAULT; @@ -89,6 +97,7 @@ public void setCopyText(String copyText) { } public void addButton(LytButton button) { + button.setColor(toolbarText); extraButtons.add(button); append(button); if (getDocument() != null) getDocument().invalidateLayout(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java index 55eac157..ec0f84f3 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidCanvas.java @@ -23,6 +23,8 @@ import com.hfstudio.guidenh.guide.document.interaction.FlowInteractionPath; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; +import com.hfstudio.guidenh.guide.internal.debug.DebugFlowContainer; import com.hfstudio.guidenh.guide.internal.recipe.LytNeiRecipeBox; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.render.GuiSprite; @@ -55,6 +57,14 @@ public abstract class LytMermaidCanvas> extends Ly private final Map scaledStyleCache = new IdentityHashMap<>(); private float lastScaledStyleZoom = Float.NaN; + @Nullable + private Object debugComponentLayout; + @Nullable + private LytRect debugComponentViewport; + private int debugComponentOffsetX; + private int debugComponentOffsetY; + private float debugComponentZoom; + private List cachedDebugComponents = List.of(); // Common interaction state protected Map nodeContentBlocks; @@ -256,6 +266,12 @@ public void centerDiagram(int viewportWidth, int viewportHeight, int diagramWidt zoom); } + public void resetView() { + zoom = 1f; + centerDiagram(contentWidth(), contentHeight()); + clampOffsets(); + } + @Override public boolean beginDrag(int documentX, int documentY, int button) { if (!diagramReady()) return false; @@ -360,11 +376,37 @@ protected ResolvedTextStyle getOrScaleStyle(ResolvedTextStyle base, float zoom) return MermaidNodeRenderer.getOrScaleStyle(scaledStyleCache, base, zoom); } - public static int clampAxis(int offset, int viewportSize, int contentSize) { - if (contentSize <= viewportSize) { - return (viewportSize - contentSize) / 2; + @Nullable + protected List getCachedDebugComponents(Object layout) { + LytRect viewport = getInnerViewport(); + int offsetX = getVisualOffsetX(); + int offsetY = getVisualOffsetY(); + float activeZoom = getActiveZoom(); + if (layout != debugComponentLayout || !viewport.equals(debugComponentViewport) + || offsetX != debugComponentOffsetX + || offsetY != debugComponentOffsetY + || Float.compare(activeZoom, debugComponentZoom) != 0) { + return null; } - return Math.clamp(offset, viewportSize - contentSize, 0); + return cachedDebugComponents; + } + + protected List cacheDebugComponents(Object layout, + List components) { + debugComponentLayout = layout; + debugComponentViewport = getInnerViewport(); + debugComponentOffsetX = getVisualOffsetX(); + debugComponentOffsetY = getVisualOffsetY(); + debugComponentZoom = getActiveZoom(); + cachedDebugComponents = List.copyOf(components); + return cachedDebugComponents; + } + + public static int clampAxis(int offset, int viewportSize, int contentSize) { + int minimumVisible = Math.max(1, Math.min(contentSize, viewportSize) / 2); + int minOffset = minimumVisible - contentSize; + int maxOffset = viewportSize - minimumVisible; + return Math.clamp(offset, minOffset, maxOffset); } public static int scaled(int base, int value, float activeZoom) { @@ -519,6 +561,81 @@ protected static LytRect resolveNodeContentRect(NodeContentLayout contentLayout, .height() * activeZoom))); } + protected void collectNodeContentDebugComponents(NodeContentLayout contentLayout, LytRect contentViewport, + float activeZoom, String nodeName, int priority, List components) { + if (contentLayout == null || contentViewport == null + || contentLayout.visualBounds() + .isEmpty()) { + return; + } + int originX = contentViewport.x() - Math.round( + contentLayout.visualBounds() + .x() * activeZoom); + int originY = contentViewport.y() - Math.round( + contentLayout.visualBounds() + .y() * activeZoom); + collectNodeContentDebugComponents( + contentLayout.block(), + originX, + originY, + activeZoom, + nodeName, + priority, + components, + 0); + } + + private void collectNodeContentDebugComponents(LytNode node, int originX, int originY, float activeZoom, + String nodeName, int priority, List components, int depth) { + LytRect localBounds = node.getBounds(); + if (localBounds != null && !localBounds.isEmpty()) { + LytRect screenBounds = new LytRect( + originX + Math.round(localBounds.x() * activeZoom), + originY + Math.round(localBounds.y() * activeZoom), + Math.max(1, Math.round(localBounds.width() * activeZoom)), + Math.max(1, Math.round(localBounds.height() * activeZoom))); + components.add( + new DebugComponent.SimpleComponentEntry( + "NodeContent:" + nodeName + + ":" + + node.getClass() + .getSimpleName(), + screenBounds, + null, + priority + depth)); + if (node instanceof DebugFlowContainer flowContainer) { + for (DebugFlowContainer.FlowContentEntry entry : flowContainer.getAllFlowContent()) { + LytRect flowBounds = entry.bounds(); + components.add( + new DebugComponent.SimpleComponentEntry( + "NodeContent:" + nodeName + + ":" + + entry.content() + .getClass() + .getSimpleName(), + new LytRect( + originX + Math.round(flowBounds.x() * activeZoom), + originY + Math.round(flowBounds.y() * activeZoom), + Math.max(1, Math.round(flowBounds.width() * activeZoom)), + Math.max(1, Math.round(flowBounds.height() * activeZoom))), + null, + priority + depth + 10)); + } + } + } + for (LytNode child : node.getChildren()) { + collectNodeContentDebugComponents( + child, + originX, + originY, + activeZoom, + nodeName, + priority, + components, + depth + 1); + } + } + public record NodeHit(LytNode node, FlowInteractionPath flowPath, int localX, int localY) { public NodeHit(LytNode node, @Nullable FlowInteractionPath flowPath, int localX, int localY) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java index 55f40618..c85b1035 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchart.java @@ -55,11 +55,20 @@ public LytMermaidFlowchart(FlowchartDocument flowchart, String sourceText, Map canvas.resetView()); + button.setTooltipText(GuidebookText.ResetView.text()); + button.setHoverColor(SymbolicColor.ICON_BUTTON_HOVER); + return button; + } + public void setPreferredSize(int width, int height) { canvas.setPreferredSize(width, height); toolbar.setPreferredWidth(width); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java index 2097c2c1..be4e3e2d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidFlowchartCanvas.java @@ -11,6 +11,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.block.shapes.FlowchartShapes; import com.hfstudio.guidenh.guide.document.interaction.DocumentInteractionSnapshot; +import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidArrowHead; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidEdgeStyle; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidNodeShape; @@ -29,7 +30,7 @@ import com.hfstudio.guidenh.guide.style.TextAlignment; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; -public class LytMermaidFlowchartCanvas extends LytMermaidCanvas { +public class LytMermaidFlowchartCanvas extends LytMermaidCanvas implements DebugComponent { private static final int CANVAS_PADDING = 10; private static final int MIN_WIDTH = 96; @@ -956,4 +957,63 @@ private static int parseHexColor(String hex) { } catch (NumberFormatException ignored) {} return 0; } + + @Override + public List getDebugComponents() { + List cachedComponents = getCachedDebugComponents(layout); + if (cachedComponents != null) { + return cachedComponents; + } + List components = new ArrayList<>(); + if (layout == null || bounds == null) { + return components; + } + LytRect viewport = getInnerViewport(); + float zoom = getActiveZoom(); + int baseX = viewport.x() + getVisualOffsetX() - getScaledOriginX(); + int baseY = viewport.y() + getVisualOffsetY() - getScaledOriginY(); + + for (EdgePath edge : layout.getEdgePaths()) { + List points = edge.getPoints(); + for (int index = 1; index < points.size(); index++) { + var from = points.get(index - 1); + var to = points.get(index); + components.add( + new LineComponentEntry( + "Edge:" + edge.getFromId() + "->" + edge.getToId(), + scaled(baseX, from.getX(), zoom), + scaled(baseY, from.getY(), zoom), + scaled(baseX, to.getX(), zoom), + scaled(baseY, to.getY(), zoom), + Math.max(3, Math.round(CONNECTOR_THICKNESS * zoom) + 2), + null, + 10)); + } + } + + for (var entry : layout.getNodePositions() + .entrySet()) { + NodePosition position = entry.getValue(); + int x = scaled(baseX, position.getX(), zoom); + int y = scaled(baseY, position.getY(), zoom); + int width = Math.max(1, Math.round(position.getWidth() * zoom)); + int height = Math.max(1, Math.round(position.getHeight() * zoom)); + LytRect nodeBounds = new LytRect(x, y, width, height); + FlowchartNode node = document.getNodes() + .get(entry.getKey()); + String label = node != null && node.getLabel() != null ? node.getLabel() : entry.getKey(); + components.add(new SimpleComponentEntry("Node:" + label, nodeBounds, null, 20)); + LytRect contentBounds = nodeBounds.shrink( + Math.max(1, Math.round(NODE_PADDING_X * zoom)), + Math.max(1, Math.round(NODE_PADDING_Y * zoom)), + Math.max(1, Math.round(NODE_PADDING_X * zoom)), + Math.max(1, Math.round(NODE_PADDING_Y * zoom))); + components.add(new SimpleComponentEntry("Label:" + label, contentBounds, null, 25)); + NodeContentLayout contentLayout = nodeContentLayouts.get(entry.getKey()); + if (contentLayout != null) { + collectNodeContentDebugComponents(contentLayout, contentBounds, zoom, label, 30, components); + } + } + return cacheDebugComponents(layout, components); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmap.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmap.java index 59f18c92..91a224a2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmap.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmap.java @@ -5,8 +5,10 @@ import java.util.Optional; import com.hfstudio.guidenh.guide.color.SymbolicColor; +import com.hfstudio.guidenh.guide.document.LytSize; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapDocument; import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; @@ -41,11 +43,20 @@ public LytMermaidMindmap(MindmapDocument mindmap, String sourceText, Map canvas.resetView()); + button.setTooltipText(GuidebookText.ResetView.text()); + button.setHoverColor(SymbolicColor.ICON_BUTTON_HOVER); + return button; + } + public void setPreferredSize(int width, int height) { canvas.setPreferredSize(width, height); toolbar.setPreferredWidth(width); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java index 0750ccbb..6498a35d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytMermaidMindmapCanvas.java @@ -823,6 +823,10 @@ public int centerY() { @Override public List getDebugComponents() { + List cachedComponents = getCachedDebugComponents(layout); + if (cachedComponents != null) { + return cachedComponents; + } List components = new ArrayList<>(); if (layout == null || bounds == null) { @@ -837,7 +841,14 @@ public List getDebugComponents() { // Collect all nodes from the layout collectNodeComponents(layout.root(), components, baseX, baseY, activeZoom); - return components; + return cacheDebugComponents(layout, components); + } + + private void addConnectorSegments(List components, int x1, int y1, int x2, int y2, int x3, int y3, + int x4, int y4) { + components.add(new LineComponentEntry("Connector", x1, y1, x2, y2, CONNECTOR_THICKNESS + 2, null, 10)); + components.add(new LineComponentEntry("Connector", x2, y2, x3, y3, CONNECTOR_THICKNESS + 2, null, 10)); + components.add(new LineComponentEntry("Connector", x3, y3, x4, y4, CONNECTOR_THICKNESS + 2, null, 10)); } private void collectNodeComponents(NodeLayout node, List components, int baseX, int baseY, @@ -906,11 +917,43 @@ private void collectNodeComponents(NodeLayout node, List compone activeZoom); components.add( new SimpleComponentEntry("NodeContent", contentRect, "Block content for: " + nodeName, priority + 3)); + collectNodeContentDebugComponents( + node.contentLayout, + contentRect, + activeZoom, + nodeName, + priority + 4, + components); } // Recursively collect children for (NodeLayout child : node.children) { + collectConnectorComponents(node, child, components, baseX, baseY, activeZoom); collectNodeComponents(child, components, baseX, baseY, activeZoom); } } + + private void collectConnectorComponents(NodeLayout parent, NodeLayout child, List components, + int baseX, int baseY, float activeZoom) { + int startX; + int startY; + int endX; + int endY; + if (mindmap.getLayoutMode() == MindmapLayoutMode.TIDY_TREE) { + startX = scaled(baseX, parent.centerX(), activeZoom); + startY = scaled(baseY, parent.bottom(), activeZoom); + endX = scaled(baseX, child.centerX(), activeZoom); + endY = scaled(baseY, child.y, activeZoom); + int middleY = (startY + endY) / 2; + addConnectorSegments(components, startX, startY, startX, middleY, endX, middleY, endX, endY); + return; + } + boolean rightSide = child.centerX() >= parent.centerX(); + startX = scaled(baseX, rightSide ? parent.right() : parent.x, activeZoom); + startY = scaled(baseY, parent.centerY(), activeZoom); + endX = scaled(baseX, rightSide ? child.x : child.right(), activeZoom); + endY = scaled(baseY, child.centerY(), activeZoom); + int middleX = (startX + endX) / 2; + addConnectorSegments(components, startX, startY, middleX, startY, middleX, endY, endX, endY); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java index 877711a3..57343c22 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/functiongraph/LytFunctionGraph.java @@ -14,6 +14,7 @@ import com.hfstudio.guidenh.guide.document.interaction.DocumentDragTarget; import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; @@ -25,14 +26,14 @@ /** * Function graph block. Plots one or more {@link FunctionPlot} curves on a Cartesian panel with - * interactive Desmos-style hovering: while the cursor is over a curve the segment is thickened, an - * accent point is drawn at the cursor's x value, and a custom tooltip anchored to the point is - * rendered. Pressing the mouse button latches the highlight onto that curve so the user can drag - * along it freely until the button is released, even when the cursor strays vertically. + * interactive Desmos-style hovering: while the cursor is over a curve the segment is thickened and an + * accent point is drawn at the cursor's x value. Pressing the mouse button latches the highlight + * onto that curve so the user can drag along it freely until the button is released, even when the + * cursor strays vertically. * *

- * Layout, sampling and the tooltip overlay are all handled inside this single block so the rest - * of the document does not need to coordinate with it. + * Layout, sampling and hover-state tracking are all handled inside this single block so the rest of + * the document does not need to coordinate with it. */ public class LytFunctionGraph extends LytBlock implements InteractiveElement, DocumentDragTarget { @@ -50,9 +51,6 @@ public class LytFunctionGraph extends LytBlock implements InteractiveElement, Do private static final float HIGHLIGHT_LINE_BONUS = 1.0f; private static final int POINT_RADIUS = 3; private static final float POINT_OUTER_RING = 1f; - private static final int TOOLTIP_PADDING_X = 5; - private static final int TOOLTIP_PADDING_Y = 4; - private static final int TOOLTIP_GAP = 8; private static final int LEGEND_GAP_ABOVE = 4; private static final int LEGEND_ROW_GAP = 2; private static final int LEGEND_ITEM_GAP = 10; @@ -67,7 +65,6 @@ public class LytFunctionGraph extends LytBlock implements InteractiveElement, Do private static final ResolvedTextStyle TITLE_STYLE = makeStyle(0xFFE6E6E6, true); private static final ResolvedTextStyle AXIS_LABEL_STYLE = makeStyle(0xFFB8C2CF, false); - private static final ResolvedTextStyle TOOLTIP_TITLE_STYLE = makeStyle(0xFFFFFFFF, false); private static final ResolvedTextStyle TOOLTIP_BODY_STYLE = makeStyle(0xFFD7DEE7, false); private static final ResolvedTextStyle LEGEND_LABEL_STYLE = makeStyle(0xFFD7DEE7, false); @@ -295,8 +292,7 @@ public Optional getTooltip(float x, float y) { if (!isDragging) { updateHover(x, y); } - // Tooltip is rendered manually anchored to the point; no built-in tooltip is returned. - return Optional.empty(); + return createActiveTooltip(); } @Override @@ -918,10 +914,6 @@ private void renderActiveOverlay(RenderContext context, LytRect plotRect) { context.fillCircle(sx, sy, POINT_RADIUS + POINT_OUTER_RING, 0xFFFFFFFF); context.fillCircle(sx, sy, POINT_RADIUS, plot.getColor()); - // Tooltip panel. - String line1 = !isEmpty(plot.getLabel()) ? plot.getLabel() : plot.getExpressionText(); - String line2 = "(" + formatValue(dataX) + ", " + formatValue(dataY) + ")"; - renderTooltipBox(context, sx, sy, line1, line2); } private void renderMarkedPointOverlay(RenderContext context, LytRect plotRect) { @@ -938,10 +930,6 @@ private void renderMarkedPointOverlay(RenderContext context, LytRect plotRect) { context.drawCircleOutline(sx, sy, POINT_RADIUS + 2f, 1f, 0xFF000000); context.fillCircle(sx, sy, POINT_RADIUS, color); - MarkedPoint point = points.get(activeMarkedIndex); - String line1 = !isEmpty(point.getLabel()) ? point.getLabel() : "Point"; - String line2 = "(" + formatValue(dataX) + ", " + formatValue(dataY) + ")"; - renderTooltipBox(context, sx, sy, line1, line2); } private void renderAutoPointOverlay(RenderContext context, LytRect plotRect) { @@ -957,31 +945,44 @@ private void renderAutoPointOverlay(RenderContext context, LytRect plotRect) { context.drawCircleOutline(sx, sy, POINT_RADIUS + 2f, 1f, 0xFF000000); context.fillCircle(sx, sy, POINT_RADIUS, color); - FunctionPlot plot = plots.get(activeAutoPlotIndex); - String line1 = !isEmpty(plot.getLabel()) ? plot.getLabel() : plot.getExpressionText(); - String line2 = "(" + formatValue(dataX) + ", " + formatValue(dataY) + ")"; - renderTooltipBox(context, sx, sy, line1, line2); } - private void renderTooltipBox(RenderContext context, float sx, float sy, String line1, String line2) { - int lineH = context.getLineHeight(TOOLTIP_BODY_STYLE); - int textWidth = Math - .max(context.getStringWidth(line1, TOOLTIP_TITLE_STYLE), context.getStringWidth(line2, TOOLTIP_BODY_STYLE)); - int boxWidth = textWidth + TOOLTIP_PADDING_X * 2; - int boxHeight = lineH * 2 + TOOLTIP_PADDING_Y * 2; - int boxX = (int) sx - boxWidth / 2; - int boxY = (int) sy - boxHeight - TOOLTIP_GAP; - if (boxY < bounds.y() + 2) { - boxY = (int) sy + TOOLTIP_GAP; - } - boxX = Math.clamp(boxX, bounds.x() + 2, bounds.right() - boxWidth - 2); - boxY = Math.clamp(boxY, bounds.y() + 2, bounds.bottom() - boxHeight - 2); - - LytRect box = new LytRect(boxX, boxY, boxWidth, boxHeight); - context.fillRect(box, 0xEE202428); - context.drawBorder(box, 0xFF555555, 1); - context.drawText(line1, boxX + TOOLTIP_PADDING_X, boxY + TOOLTIP_PADDING_Y, TOOLTIP_TITLE_STYLE); - context.drawText(line2, boxX + TOOLTIP_PADDING_X, boxY + TOOLTIP_PADDING_Y + lineH, TOOLTIP_BODY_STYLE); + private Optional createActiveTooltip() { + if (activeMarkedIndex >= 0 && activeMarkedIndex < points.size()) { + MarkedPoint point = points.get(activeMarkedIndex); + return coordinateTooltip( + !isEmpty(point.getLabel()) ? point.getLabel() : "Point", + activeMarkedDataX, + activeMarkedDataY); + } + if (activeAutoPlotIndex >= 0 && activeAutoPlotIndex < plots.size()) { + FunctionPlot plot = plots.get(activeAutoPlotIndex); + return coordinateTooltip( + !isEmpty(plot.getLabel()) ? plot.getLabel() : plot.getExpressionText(), + activeAutoDataX, + activeAutoDataY); + } + if (activePlotIndex < 0 || activePlotIndex >= plots.size()) { + return Optional.empty(); + } + FunctionPlot plot = plots.get(activePlotIndex); + double dataX; + double dataY; + if (plot.isInverse()) { + dataY = activeDataX; + dataX = plot.evaluate(dataY); + } else { + dataX = activeDataX; + dataY = plot.evaluate(dataX); + } + if (!Double.isFinite(dataX) || !Double.isFinite(dataY)) { + return Optional.empty(); + } + return coordinateTooltip(!isEmpty(plot.getLabel()) ? plot.getLabel() : plot.getExpressionText(), dataX, dataY); + } + + private Optional coordinateTooltip(String label, double dataX, double dataY) { + return Optional.of(new TextTooltip(label + "\n(" + formatValue(dataX) + ", " + formatValue(dataY) + ")")); } /** @@ -1363,11 +1364,4 @@ private static ResolvedTextStyle makeStyle(int argb, boolean bold) { null, false); } - - @SuppressWarnings("unused") - private int unusedDragButtonAccessor() { - // The drag button is captured for future use (e.g. distinguishing left/right behaviour) but - // is not consulted today; this accessor keeps it from being trimmed by static analysis. - return dragButton; - } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/indices/ItemIndex.java b/src/main/java/com/hfstudio/guidenh/guide/indices/ItemIndex.java index deb2cfd8..55b97f7d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/ItemIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/ItemIndex.java @@ -73,8 +73,7 @@ public static List> getItemAnchors(ParsedGuidePage page List itemIdList = normalizeItemIdEntries(page, itemIdsNode); if (itemIdList == null) { - GuideDebugLog - .warnAlways("[GuideNH] [ItemIndex] Page {} contains malformed item_ids frontmatter", page.getId()); + GuideDebugLog.warn("[GuideNH] [ItemIndex] Page {} contains malformed item_ids frontmatter", page.getId()); return List.of(); } @@ -97,7 +96,7 @@ public static List> getItemAnchors(ParsedGuidePage page page.getId() .getResourceDomain()); } catch (IllegalArgumentException e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [ItemIndex] Page {} contains a malformed item_ids frontmatter entry: {}", page.getId(), listEntry); @@ -105,7 +104,7 @@ public static List> getItemAnchors(ParsedGuidePage page } if (itemId == null) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [ItemIndex] Page {} references an unknown item {} in its item_ids frontmatter", page.getId(), listEntry); @@ -114,7 +113,7 @@ public static List> getItemAnchors(ParsedGuidePage page itemAnchors.add(Pair.of(itemId, new PageAnchor(page.getId(), anchor))); } else { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [ItemIndex] Page {} contains a malformed item_ids frontmatter entry: {}", page.getId(), listEntry); @@ -132,9 +131,8 @@ private static List normalizeItemIdEntries(ParsedGuidePage page, Object itemI if (itemIdsNode instanceof String itemIdEntry) { String trimmed = itemIdEntry.trim(); if (trimmed.isEmpty()) { - GuideDebugLog.warnAlways( - "[GuideNH] [ItemIndex] Page {} contains an empty item_ids frontmatter entry", - page.getId()); + GuideDebugLog + .warn("[GuideNH] [ItemIndex] Page {} contains an empty item_ids frontmatter entry", page.getId()); return List.of(); } return List.of(trimmed); diff --git a/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java b/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java index 52d5b4e2..5f3c3e58 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java @@ -115,8 +115,7 @@ public static List> getOreAnchors(ParsedGuidePage page) } if (!(oreIdsNode instanceof ListoreIdList)) { - GuideDebugLog - .warnAlways("[GuideNH] [OreIndex] Page {} contains malformed ore_ids frontmatter", page.getId()); + GuideDebugLog.warn("[GuideNH] [OreIndex] Page {} contains malformed ore_ids frontmatter", page.getId()); return List.of(); } @@ -126,14 +125,13 @@ public static List> getOreAnchors(ParsedGuidePage page) if (listEntry instanceof String oreName) { String trimmed = oreName.trim(); if (trimmed.isEmpty()) { - GuideDebugLog.warnAlways( - "[GuideNH] [OreIndex] Page {} contains an empty ore_ids frontmatter entry", - page.getId()); + GuideDebugLog + .warn("[GuideNH] [OreIndex] Page {} contains an empty ore_ids frontmatter entry", page.getId()); continue; } oreAnchors.add(Pair.of(trimmed, new PageAnchor(page.getId(), null))); } else { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [OreIndex] Page {} contains a malformed ore_ids frontmatter entry: {}", page.getId(), listEntry); diff --git a/src/main/java/com/hfstudio/guidenh/guide/indices/UniqueIndex.java b/src/main/java/com/hfstudio/guidenh/guide/indices/UniqueIndex.java index 3b98bda9..c9066391 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/UniqueIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/UniqueIndex.java @@ -99,7 +99,7 @@ private void addToIndex(ParsedGuidePage page) { var value = entry.getValue(); var previousPage = index.putIfAbsent(key, new Record<>(page.getId(), value)); if (previousPage != null) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [UniqueIndex] Key conflict in index {}: {} is used by pages {} and {}; keeping {} and ignoring {}", name, key, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java index 761b8464..e7969533 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java @@ -101,8 +101,7 @@ public static void reloadGuides(IResourceManager resourceManager) { GuideME.getSearch() .indexAll(); } catch (Throwable t) { - GuideDebugLog - .warnAlways("[GuideNH] [GuideLightweightReloadService] Failed to reindex search after reload", t); + GuideDebugLog.warn("[GuideNH] [GuideLightweightReloadService] Failed to reindex search after reload", t); } } @@ -222,11 +221,8 @@ private static ParsedGuidePage parsePageBytes(String sourcePack, String language return GuideLocalizedPageSourceResolver .parseFrontmatterOnly(sourcePack, language, contentRootFolder, pageId, bytes); } catch (Exception ex) { - GuideDebugLog.warnAlways( - "[GuideNH] [GuideLightweightReloadService] Error parsing page {} from {}", - pageId, - sourceId, - ex); + GuideDebugLog + .warn("[GuideNH] [GuideLightweightReloadService] Error parsing page {} from {}", pageId, sourceId, ex); return null; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideMEClientProxy.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideMEClientProxy.java index b5dc041d..52beb7ea 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideMEClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideMEClientProxy.java @@ -6,6 +6,7 @@ import org.jetbrains.annotations.Nullable; +import com.hfstudio.guidenh.client.GuideNhClientTaskScheduler; import com.hfstudio.guidenh.guide.PageAnchor; public class GuideMEClientProxy extends GuideMEServerProxy { @@ -20,6 +21,9 @@ public boolean openGuide(EntityPlayer player, ResourceLocation guideId, @Nullabl public boolean reloadResources() { var mc = Minecraft.getMinecraft(); if (mc == null) return false; - return GuideMEClientReloadDispatcher.dispatch(mc.func_152345_ab(), mc::func_152344_a, mc::refreshResources); + return GuideMEClientReloadDispatcher.dispatch( + GuideNhClientTaskScheduler.isOnClientThread(), + GuideNhClientTaskScheduler::execute, + mc::refreshResources); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java index 38949e1f..60be04c6 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java @@ -91,6 +91,7 @@ import com.hfstudio.guidenh.guide.internal.compile.CompileWorker; import com.hfstudio.guidenh.guide.internal.datadriven.DataDrivenGuideLoader; import com.hfstudio.guidenh.guide.internal.datadriven.GuidePageResourceSelector; +import com.hfstudio.guidenh.guide.internal.debug.DebugComponent; import com.hfstudio.guidenh.guide.internal.debug.GuideDebugOverlay; import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorMultilineTextArea; import com.hfstudio.guidenh.guide.internal.editor.guide.GuideScreenEditorAction; @@ -117,6 +118,7 @@ import com.hfstudio.guidenh.guide.internal.screen.GuideIconButton; import com.hfstudio.guidenh.guide.internal.screen.GuideNavBar; import com.hfstudio.guidenh.guide.internal.screen.GuideNavBar.ContextTarget; +import com.hfstudio.guidenh.guide.internal.screen.GuideNavBar.ExpansionChange; import com.hfstudio.guidenh.guide.internal.screen.GuideNavBarState; import com.hfstudio.guidenh.guide.internal.search.GuideItemLinksPage; import com.hfstudio.guidenh.guide.internal.search.GuideSearchPage; @@ -307,6 +309,8 @@ private static LytDocument buildLoadingDocument() { private LytDocument searchDocument; @Nullable private String cachedSearchQuery; + private long cachedSearchIndexRevision = -1L; + private long searchDocumentRebuildAfterNanos; // Tracks the item stack whose tooltip was rendered last frame, for the G-key disambiguation hotkey. @Nullable @@ -388,6 +392,7 @@ private static LytDocument buildLoadingDocument() { public static final int SEARCH_RESULT_ICON_AND_GAP = 22; public static final int SEARCH_RESULT_TITLE_GAP = 8; public static final int SEARCH_PATH_MAX_CHARS = 20; + private static final long SEARCH_QUERY_DEBOUNCE_NANOS = 75_000_000L; public static final String ASCII_ELLIPSIS = "..."; public static final int SEARCH_TOOLBAR_FIELD_Y_OFFSET = 5; private static final int SPECIAL_SEARCH_BACKGROUND_PADDING_X = 4; @@ -540,17 +545,13 @@ private GuideScreen(GuideScreenRoute route, @Nullable GuideScreenViewState resto bookmarkState, route.isContent() && currentAnchor != null ? currentAnchor.pageId() : null, null); - navBar.setOnExpansionToggled((toggledGuideId, pageId, expanded) -> { - if (toggledGuideId != null) { - updateSavedExpansionState(toggledGuideId, pageId, expanded); - } + navBar.setOnExpansionChanged((changes, allCollapsed) -> { ResourceLocation currentGuideId = guide != null ? guide.getId() : null; - if (!Objects.equals(currentGuideId, toggledGuideId)) { + updateSavedExpansionStates(changes, currentGuideId); + if (allCollapsed || changes.stream() + .anyMatch(change -> !Objects.equals(currentGuideId, change.guideId()))) { rememberNavigationState(); } - if (toggledGuideId == null && currentGuideId != null) { - updateSavedExpansionState(null, pageId, expanded); - } }); } @@ -615,7 +616,7 @@ private static GuideScreenViewState contentState(ResourceLocation guideId, @Null private static GuideScreenRoute contentRoute(ResourceLocation guideId, @Nullable PageAnchor anchor) { MutableGuide guide = GuideRegistry.getById(guideId); if (guide == null) { - GuideDebugLog.warnAlways("GuideScreen.open: no guide registered with id {}", guideId); + GuideDebugLog.warn("GuideScreen.open: no guide registered with id {}", guideId); return null; } if (anchor == null) { @@ -764,6 +765,7 @@ private void restoreViewState(GuideScreenViewState state) { ensureLayout(); scrollToCurrentAnchor(); clampScroll(); + rebuildToolbar(); if (isGuideEditorActive()) { refreshGuideEditorDraft(true); } @@ -806,23 +808,40 @@ private void rememberNavigationState() { .rememberNavBarState(guide != null ? guide.getId() : null, navBar.captureState()); } - private void updateSavedExpansionState(@Nullable ResourceLocation guideId, ResourceLocation pageId, - boolean expanded) { - GuideNavBarState saved = ClientProxy.getLytHost() - .getNavigation() - .recallNavigationState(guideId); - LinkedHashSet updated = new LinkedHashSet<>( - saved.expandedPageIds() != null ? saved.expandedPageIds() : Collections.emptySet()); - if (expanded) { - updated.add(pageId); - } else { - updated.remove(pageId); - } - ClientProxy.getLytHost() - .getNavigation() - .rememberNavBarState( + private void updateSavedExpansionStates(List changes, @Nullable ResourceLocation currentGuideId) { + Map savedStates = new LinkedHashMap<>(); + Map> expandedPageIdsByGuide = new LinkedHashMap<>(); + for (ExpansionChange change : changes) { + ResourceLocation guideId = change.guideId(); + if (guideId == null && currentGuideId == null) { + continue; + } + GuideNavBarState saved = savedStates.computeIfAbsent( + guideId, + ignored -> ClientProxy.getLytHost() + .getNavigation() + .recallNavigationState(guideId)); + LinkedHashSet expandedPageIds = expandedPageIdsByGuide.computeIfAbsent( guideId, - GuideNavBarState.create(saved.bookmarkGroupExpanded(), updated, saved.scrollY())); + ignored -> new LinkedHashSet<>( + saved.expandedPageIds() != null ? saved.expandedPageIds() : Collections.emptySet())); + if (change.expanded()) { + expandedPageIds.add(change.pageId()); + } else { + expandedPageIds.remove(change.pageId()); + } + } + for (Map.Entry entry : savedStates.entrySet()) { + GuideNavBarState saved = entry.getValue(); + ClientProxy.getLytHost() + .getNavigation() + .rememberNavBarState( + entry.getKey(), + GuideNavBarState.create( + saved.bookmarkGroupExpanded(), + expandedPageIdsByGuide.get(entry.getKey()), + saved.scrollY())); + } } private boolean isNavigationNewPageButtonVisible() { @@ -1200,8 +1219,7 @@ private void createGuideEditorPageAtPath(MutableGuide activeGuide, ParsedGuidePa Path sourceRoot = guideEditorFileStore .findWritablePageResourcePackRoot(activeGuide, currentParsedPage.getId(), language); if (sourceRoot == null) { - GuideDebugLog - .warnAlways("Failed to create guide editor page because current page has no writable resource pack"); + GuideDebugLog.warn("Failed to create guide editor page because current page has no writable resource pack"); return; } @@ -1510,7 +1528,7 @@ private void rebuildGuideEditorPreview() { } cachedGuideEditorPreviewInteractionState = null; } catch (Throwable t) { - GuideDebugLog.warnAlways("Failed to compile guide editor preview for {}", currentAnchor.pageId(), t); + GuideDebugLog.warn("Failed to compile guide editor preview for {}", currentAnchor.pageId(), t); } } @@ -1761,7 +1779,7 @@ private void refreshGuideEditorPreviewState() { scheduleGuideEditorNavigationRefresh(); } } catch (Throwable t) { - GuideDebugLog.warnAlways("Failed to refresh guide editor draft state for {}", currentAnchor.pageId(), t); + GuideDebugLog.warn("Failed to refresh guide editor draft state for {}", currentAnchor.pageId(), t); } } @@ -1862,7 +1880,7 @@ private void updateGuideEditorNavigationRefresh() { GuideME.getSearch() .index(guide); } catch (Throwable t) { - GuideDebugLog.warnAlways("Guide editor navigation refresh failed", t); + GuideDebugLog.warn("Guide editor navigation refresh failed", t); } } @@ -2227,7 +2245,6 @@ protected void actionPerformed(GuiButton btn) { bookmarkState, null, null); - rebuildToolbar(); }); } else if (btn == btnGuideEditorToggle) { toggleGuideEditorEnabled(); @@ -2289,7 +2306,6 @@ private void navigateBackInHistory() { .pageId() : null, carryOver); } - rebuildToolbar(); } }); } @@ -2327,7 +2343,6 @@ private void navigateForwardInHistory() { .pageId() : null, carryOver); } - rebuildToolbar(); } }); } @@ -2856,21 +2871,104 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { } drawButtonTooltip(mouseX, mouseY); debugOverlay.onFrameStart(); + renderDebugOverlay(mouseX, mouseY); + } + + private void renderDebugOverlay(int mouseX, int mouseY) { + if (!ModConfig.debug.guiDebugMode) { + return; + } + if (isGuideEditorActive()) { + LytDocument previewDocument = guideEditorLayoutMode != GuideScreenEditorLayoutMode.EDITOR_ONLY + && guideEditorPreviewPage != null ? guideEditorPreviewPage.document() : null; + debugOverlay.render( + width, + height, + mouseX, + mouseY, + getGuideEditorPreviewX(), + getGuideEditorContentTop(), + getGuideEditorPreviewPaneWidth(), + getGuideEditorPreviewPaneHeight(), + guideEditorPreviewScrollY, + 1.0f, + previewDocument, + getGuideEditorDebugComponents(), + fontRendererObj); + return; + } + var activeDocument = getActiveDocument(); debugOverlay.render( width, height, mouseX, mouseY, contentX, - getDocumentViewportY(), + activeDocument != null ? getDocumentRenderY(activeDocument) : getDocumentViewportY(), contentW, getDocumentViewportHeight(), Math.round(visualScrollY), currentZoom, - layoutDocument, + activeDocument, fontRendererObj); } + private List getGuideEditorDebugComponents() { + List components = new ArrayList<>(buttonList.size() + 4); + for (Object buttonObject : buttonList) { + if (buttonObject instanceof GuideIconButton button && button.visible) { + components.add( + new DebugComponent.SimpleComponentEntry( + "Button:" + button.getRole() + .name(), + new LytRect(button.xPosition, button.yPosition, button.width, button.height), + button.getTooltip(), + 100)); + } + } + if (guideEditorLayoutMode != GuideScreenEditorLayoutMode.PREVIEW_ONLY && guideEditorTextArea != null) { + components.add( + new DebugComponent.SimpleComponentEntry("MarkdownEditor", guideEditorTextArea.getBounds(), null, 100)); + } + if (guideEditorLayoutMode != GuideScreenEditorLayoutMode.EDITOR_ONLY) { + int previewX = getGuideEditorPreviewX(); + int previewY = getGuideEditorContentTop(); + int previewWidth = getGuideEditorPreviewPaneWidth(); + int previewHeight = getGuideEditorPreviewPaneHeight(); + if (previewWidth > 0 && previewHeight > 0) { + components.add( + new DebugComponent.SimpleComponentEntry( + "PreviewPane", + new LytRect(previewX, previewY, previewWidth, previewHeight), + null, + -1)); + if (guideEditorPreviewPage != null && guideEditorPreviewPage.document() != null + && guideEditorPreviewPage.document() + .getContentHeight() > previewHeight) { + components.add( + new DebugComponent.SimpleComponentEntry( + "PreviewScrollbar", + new LytRect(previewX + previewWidth - SCROLLBAR_W, previewY, SCROLLBAR_W, previewHeight), + null, + 100)); + } + } + } + if (guideEditorLayoutMode == GuideScreenEditorLayoutMode.SPLIT) { + components.add( + new DebugComponent.SimpleComponentEntry( + "SplitDivider", + new LytRect( + getGuideEditorDividerX() - 2, + getGuideEditorContentTop(), + 5, + getGuideEditorPreviewPaneHeight()), + null, + 100)); + } + return components; + } + private void drawGuideButtons(int mouseX, int mouseY) { for (Object buttonObject : buttonList) { if (buttonObject instanceof GuiButton button) { @@ -2922,12 +3020,12 @@ private static ResourceLocation getHomeLogoTexture() { try (InputStream inputStream = GuideScreen.class.getResourceAsStream(HOME_LOGO_RESOURCE_PATH)) { if (inputStream == null) { - GuideDebugLog.warnAlways("GuideScreen home logo resource not found at {}", HOME_LOGO_RESOURCE_PATH); + GuideDebugLog.warn("GuideScreen home logo resource not found at {}", HOME_LOGO_RESOURCE_PATH); return null; } BufferedImage image = ImageIO.read(inputStream); if (image == null) { - GuideDebugLog.warnAlways("GuideScreen home logo failed to decode at {}", HOME_LOGO_RESOURCE_PATH); + GuideDebugLog.warn("GuideScreen home logo failed to decode at {}", HOME_LOGO_RESOURCE_PATH); return null; } homeLogoWidth = image.getWidth(); @@ -2937,7 +3035,7 @@ private static ResourceLocation getHomeLogoTexture() { .getDynamicTextureLocation(HOME_LOGO_SOURCE.getResourcePath(), new DynamicTexture(image)); return homeLogoTexture; } catch (Exception e) { - GuideDebugLog.warnAlways("GuideScreen failed to load home logo from {}", HOME_LOGO_RESOURCE_PATH, e); + GuideDebugLog.warn("GuideScreen failed to load home logo from {}", HOME_LOGO_RESOURCE_PATH, e); return null; } } @@ -3160,7 +3258,7 @@ private void renderGuideEditorPreview(int x, int y, int width, int height) { try { previewDocument.render(reusableRenderCtx); } catch (Throwable t) { - GuideDebugLog.warnAlways("Failed to render guide editor preview", t); + GuideDebugLog.warn("Failed to render guide editor preview", t); } finally { GL11.glPopMatrix(); reusableRenderCtx.restoreExternalRenderState(); @@ -4262,7 +4360,7 @@ private void drawContentTooltip(ContentTooltip ct, int mouseX, int mouseY, ct.getContent() .render(ctx); } catch (Throwable t) { - GuideDebugLog.warnAlways("Error rendering ContentTooltip", t); + GuideDebugLog.warn("Error rendering ContentTooltip", t); } finally { GL11.glPopMatrix(); ctx.restoreExternalRenderState(); @@ -4650,7 +4748,7 @@ private boolean canSearchCurrentView() { private void drawTiledBackground() { drawRect(0, 0, this.width, this.height, BACKGROUND_DIM_COLOR); if (mc == null || mc.getTextureManager() == null) { - GuideDebugLog.warnAlways("[GuideNH] drawTiledBackground: mc or textureManager is null, skipping"); + GuideDebugLog.warn("[GuideNH] drawTiledBackground: mc or textureManager is null, skipping"); return; } mc.getTextureManager() @@ -4901,10 +4999,8 @@ protected void mouseClicked(int mouseX, int mouseY, int button) { } if (button == 1 && navBar.contains(mouseX, mouseY)) { ContextTarget contextTarget = navBar.getContextTarget(mouseX, mouseY); - if (contextTarget != null) { - openNavBarContextMenu(mouseX, mouseY, contextTarget); - return; - } + openNavBarContextMenu(mouseX, mouseY, contextTarget); + return; } if (GuideScreenNeiBridge.mouseClicked(this, mouseX, mouseY, button)) { return; @@ -4916,7 +5012,8 @@ protected void mouseClicked(int mouseX, int mouseY, int button) { guide != null ? guide.getId() : null, currentAnchor != null ? currentAnchor.pageId() : null, bookmarkState, - isNavigationNewPageButtonVisible()); + isNavigationNewPageButtonVisible(), + Keyboard.isKeyDown(Keyboard.KEY_LSHIFT) || Keyboard.isKeyDown(Keyboard.KEY_RSHIFT)); if (result != null && result.pinToggle()) { toggleNavigationPinned(); mc.getSoundHandler() @@ -5119,7 +5216,7 @@ private void openHomePageContextMenu(int mouseX, int mouseY) { homePageContextMenu.open(mouseX, mouseY, width, height, fontRendererObj); } - private void openNavBarContextMenu(int mouseX, int mouseY, ContextTarget contextTarget) { + private void openNavBarContextMenu(int mouseX, int mouseY, @Nullable ContextTarget contextTarget) { closeHomePageContextMenu(); closeGuideEditorContextMenu(); closeNavBarContextMenu(); @@ -5164,6 +5261,14 @@ private void performHomePageContextMenuAction(GuideScreenContextMenu.ContextMenu private void performNavBarContextMenuAction(GuideScreenContextMenu.ContextMenuAction action) { ContextTarget contextTarget = navBarContextTarget; closeNavBarContextMenu(); + if (action == GuideScreenContextMenu.ContextMenuAction.EXPAND_ALL) { + navBar.expandAll(resolveNavigationTree(), bookmarkState); + return; + } + if (action == GuideScreenContextMenu.ContextMenuAction.COLLAPSE_ALL) { + navBar.collapseAll(resolveNavigationTree(), bookmarkState); + return; + } if (action == GuideScreenContextMenu.ContextMenuAction.OPEN_SPECIAL_PAGES) { openSpecialPagesFromContextMenu(contextTarget); return; @@ -5193,8 +5298,17 @@ private void openSpecialPagesFromContextMenu(@Nullable ContextTarget contextTarg .specialPageId(activeGuide.getDefaultNamespace(), MediaWikiSpecialPageIds.SPECIAL_PAGES))); } - private List buildNavBarContextMenuEntries(ContextTarget contextTarget) { + private List buildNavBarContextMenuEntries(@Nullable ContextTarget contextTarget) { List entries = new ArrayList<>(); + entries.add( + GuideScreenContextMenu.Entry + .action(GuidebookText.NavBarExpandAll.text(), GuideScreenContextMenu.ContextMenuAction.EXPAND_ALL)); + entries.add( + GuideScreenContextMenu.Entry + .action(GuidebookText.NavBarCollapseAll.text(), GuideScreenContextMenu.ContextMenuAction.COLLAPSE_ALL)); + if (contextTarget == null) { + return entries; + } entries.add( GuideScreenContextMenu.Entry.action( GuidebookText.NavBarSpecialPages.text(), @@ -5223,10 +5337,8 @@ private boolean handleHomePageContextMenuClick(int mouseX, int mouseY, int butto } if (button == 1 && navBar.contains(mouseX, mouseY)) { ContextTarget contextTarget = navBar.getContextTarget(mouseX, mouseY); - if (contextTarget != null) { - openNavBarContextMenu(mouseX, mouseY, contextTarget); - return true; - } + openNavBarContextMenu(mouseX, mouseY, contextTarget); + return true; } boolean handled = homePageContextMenu.mouseClicked( mouseX, @@ -5257,10 +5369,8 @@ private boolean handleNavBarContextMenuClick(int mouseX, int mouseY, int button) } if (button == 1 && navBar.contains(mouseX, mouseY)) { ContextTarget contextTarget = navBar.getContextTarget(mouseX, mouseY); - if (contextTarget != null) { - openNavBarContextMenu(mouseX, mouseY, contextTarget); - return true; - } + openNavBarContextMenu(mouseX, mouseY, contextTarget); + return true; } boolean handled = navBarContextMenu .mouseClicked(mouseX, mouseY, button, this::performNavBarContextMenuAction, fontRendererObj, width, height); @@ -5780,7 +5890,6 @@ protected void mouseMovedOrUp(int mouseX, int mouseY, int state) { draggingDocument = false; return; } - if (state == 0) {} } @Nullable @@ -6087,7 +6196,6 @@ public void navigateTo(PageAnchor anchor) { suppressGuideEditorTextFocusUntilGuideHotkeyRelease(); rememberCurrentContentStateIfEligible(); restoreViewState(GuideScreenViewState.of(GuideScreenRoute.content(guide.getId(), anchor), 0)); - rebuildToolbar(); }); } @@ -6118,7 +6226,6 @@ public void navigateTo(ResourceLocation guideId, PageAnchor anchor) { bookmarkState, anchor.pageId(), carryOver); - rebuildToolbar(); }); } @@ -6472,13 +6579,22 @@ private void rebuildSearchDocumentIfNeeded(boolean force) { } String query = GuideSearchPage.queryFromAnchor(currentAnchor); - if (!force && searchDocument != null && Objects.equals(cachedSearchQuery, query)) { + if (!force && System.nanoTime() < searchDocumentRebuildAfterNanos) { + return; + } + long searchIndexRevision = GuideME.getSearch() + .getIndexRevision(); + if (!force && searchDocument != null + && Objects.equals(cachedSearchQuery, query) + && cachedSearchIndexRevision == searchIndexRevision) { return; } clearInteractionState(); cachedSearchQuery = query; searchDocument = buildSearchDocument(query); + cachedSearchIndexRevision = searchIndexRevision; + searchDocumentRebuildAfterNanos = 0L; layoutDocument = null; lastLayoutWidth = -1; } @@ -6627,7 +6743,7 @@ private LytDocument buildSearchDocument(String query) { clipSnippetForWidth(result.text(), getSearchSnippetLineWidth(textColumnWidth)))); } } catch (Throwable t) { - GuideDebugLog.warnAlways("Search failed", t); + GuideDebugLog.warn("Search failed", t); } return GuideSearchResultDocumentBuilder @@ -6765,7 +6881,7 @@ private int getDocumentSearchInset() { + SPECIAL_SEARCH_DIVIDER_HEIGHT + 6; } - return isSearchPage() ? SEARCH_FIELD_H + 14 : 0; + return 0; } private boolean handleSearchFieldKey(char typedChar, int keyCode) { @@ -6835,7 +6951,8 @@ private void updateSearchQuery(String query) { currentPage = null; document = null; refreshCurrentPageTitle(); - rebuildSearchDocumentIfNeeded(true); + searchDocumentRebuildAfterNanos = query.isEmpty() ? 0L : System.nanoTime() + SEARCH_QUERY_DEBOUNCE_NANOS; + rebuildSearchDocumentIfNeeded(false); scrollY = 0; snapVisualScrollToTarget(); invalidateScrollbarOutline(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenContextMenu.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenContextMenu.java index a7618f6b..6472b981 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenContextMenu.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenContextMenu.java @@ -32,6 +32,8 @@ public interface Listener { } public enum ContextMenuAction { + EXPAND_ALL, + COLLAPSE_ALL, OPEN_SPECIAL_PAGES, CREATE_NEW_PAGE, OPEN_CONTAINING_FOLDER diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideSourceWatcher.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideSourceWatcher.java index 2a8e4c7a..bcb88776 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideSourceWatcher.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideSourceWatcher.java @@ -754,10 +754,8 @@ private PageSource resolveActivePageSource(ResourceLocation pageId, String curre Map entries = GuidePageLanguageIndex.readPageKeys(input); return entries.get(GuideLocalizedPageSourceResolver.buildLangKey(contentRootFolder, pageId)); } catch (IOException e) { - GuideDebugLog.warnAlways( - "[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", - langFilePath, - e); + GuideDebugLog + .warn("[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", langFilePath, e); return null; } } @@ -795,7 +793,7 @@ private Set resolveNamespacesForLocalizedSources(@Nullable String namesp child.getFileName() .toString())); } catch (IOException e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideSourceWatcher] Failed to scan localized source namespaces in {}", assetsPath, e); @@ -825,10 +823,8 @@ private Map loadLocalizedSourceOverridesForNamespace(String sour try (InputStream input = Files.newInputStream(langFilePath)) { return GuidePageLanguageIndex.readPageKeys(input); } catch (IOException e) { - GuideDebugLog.warnAlways( - "[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", - langFilePath, - e); + GuideDebugLog + .warn("[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", langFilePath, e); return Map.of(); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java index 7c638297..f5b99aa5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java @@ -257,6 +257,7 @@ public enum GuidebookText implements LocalizationEnum { SceneBlockStatsTooltipCount, SceneSliderLabelFormat, SceneStructureLibTierLabel, + SceneStructureLibChannelLabel, SceneVisibleLayerLabel, SceneLoading, SceneAll, @@ -268,6 +269,8 @@ public enum GuidebookText implements LocalizationEnum { HomePage, HomePageSpecialPages, NavBarSpecialPages, + NavBarExpandAll, + NavBarCollapseAll, SpecialPageShowMore, SiteExportNoPages, SiteExportOpenGuide, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java b/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java index aa20318f..d23d90b5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java @@ -170,7 +170,7 @@ public T getIndex(Class indexClass) { @Nullable public ParsedGuidePage getParsedPage(ResourceLocation id) { if (pages == null) { - GuideDebugLog.warnAlways("[GuideNH] [MutableGuide] Can't get page {}. Pages not loaded yet.", id); + GuideDebugLog.warn("[GuideNH] [MutableGuide] Can't get page {}. Pages not loaded yet.", id); return null; } @@ -727,7 +727,7 @@ private void requestMediaWikiDerivedCacheWarmup(long revision) { requestedMediaWikiWarmupRevision = Long.MIN_VALUE; } } - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [MutableGuide] Failed to warm MediaWiki caches asynchronously for guide {} revision {}", id, revision, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java index a97c50f5..371ec6e2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/datadriven/DataDrivenGuideLoader.java @@ -233,7 +233,7 @@ private static void scanZipBuildIndex(File resourcePackFile, String folder, } } } catch (IOException e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [DataDrivenGuideLoader] Failed to scan guide pages from resource pack {}", resourcePackFile.getAbsolutePath(), e); @@ -433,7 +433,7 @@ public static void scanZipPagePaths(File resourcePackFile, String prefix, Set reso var basePacks = accessor.guidenh$getResourcePackList(); if (basePacks != null) resourcePacks.addAll(basePacks); } catch (RuntimeException e) { - GuideDebugLog.warnAlways("[GuideNH] [DataDrivenGuideLoader] Failed to inspect base resource packs", e); + GuideDebugLog.warn("[GuideNH] [DataDrivenGuideLoader] Failed to inspect base resource packs", e); } var repository = Minecraft.getMinecraft() .getResourcePackRepository(); @@ -588,7 +588,7 @@ private static void addResourceManagerResourcePacks(IResourceManager resourceMan } } } catch (RuntimeException e) { - GuideDebugLog.warnAlways("[GuideNH] [DataDrivenGuideLoader] Failed to inspect resource manager packs", e); + GuideDebugLog.warn("[GuideNH] [DataDrivenGuideLoader] Failed to inspect resource manager packs", e); } } @@ -613,7 +613,7 @@ public static byte[] readBytes(IResourcePack resourcePack, ResourceLocation reso } catch (IOException e) { return null; } catch (RuntimeException e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [DataDrivenGuideLoader] readBytes failed for {} from pack {}: {}", resourceLocation, resourcePack.getPackName(), diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugComponent.java b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugComponent.java index 43a3fb5f..8eef26fd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugComponent.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/DebugComponent.java @@ -99,4 +99,58 @@ public int getPriority() { return priority; } } + + record LineComponentEntry(String name, int x1, int y1, int x2, int y2, int tolerance, @Nullable String extraInfo, + int priority) implements ComponentEntry { + + @Override + public String getName() { + return name; + } + + @Override + public @Nullable String getExtraInfo() { + return extraInfo; + } + + @Override + public int getPriority() { + return priority; + } + + @Override + public LytRect getBounds() { + int padding = Math.max(1, tolerance); + return new LytRect( + Math.min(x1, x2) - padding, + Math.min(y1, y2) - padding, + Math.abs(x2 - x1) + padding * 2 + 1, + Math.abs(y2 - y1) + padding * 2 + 1); + } + + @Override + public boolean containsPoint(int x, int y) { + int padding = Math.max(1, tolerance); + int minX = Math.min(x1, x2) - padding; + int maxX = Math.max(x1, x2) + padding; + int minY = Math.min(y1, y2) - padding; + int maxY = Math.max(y1, y2) + padding; + if (x < minX || x > maxX || y < minY || y > maxY) { + return false; + } + float dx = x2 - x1; + float dy = y2 - y1; + float lengthSquared = dx * dx + dy * dy; + if (lengthSquared == 0) { + return Math.abs(x - x1) <= tolerance && Math.abs(y - y1) <= tolerance; + } + float progress = ((x - x1) * dx + (y - y1) * dy) / lengthSquared; + progress = Math.clamp(progress, 0F, 1F); + float nearestX = x1 + progress * dx; + float nearestY = y1 + progress * dy; + float offsetX = x - nearestX; + float offsetY = y - nearestY; + return offsetX * offsetX + offsetY * offsetY <= tolerance * tolerance; + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/ElementHoverDetector.java b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/ElementHoverDetector.java index 358ade46..650e0ba9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/ElementHoverDetector.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/ElementHoverDetector.java @@ -1,8 +1,5 @@ package com.hfstudio.guidenh.guide.internal.debug; -import java.util.ArrayList; -import java.util.List; - import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.config.ModConfig; @@ -30,14 +27,13 @@ public HoveredElementInfo detectHoveredElement(LytDocument document, int mouseX, DebugInfoExtractorInit.init(); - List candidates = new ArrayList<>(); - collectHoveredNodes(document, mouseX, mouseY, null, candidates); - - return selectBestCandidate(candidates); + HoveredCandidate bestCandidate = new HoveredCandidate(); + collectHoveredNodes(document, mouseX, mouseY, null, 0, bestCandidate); + return bestCandidate.info; } private void collectHoveredNodes(LytNode node, int mouseX, int mouseY, @Nullable HoveredElementInfo parentInfo, - List candidates) { + int depth, HoveredCandidate bestCandidate) { LytRect bounds = node.getBounds(); @@ -51,15 +47,14 @@ private void collectHoveredNodes(LytNode node, int mouseX, int mouseY, @Nullable info.setCumulativeScrollOffset(cumulativeScrollX, cumulativeScrollY); - int depth = calculateDepth(node); - candidates.add(new HoveredCandidate(info, depth, bounds.width() * bounds.height())); + bestCandidate.consider(info, depth, bounds.width() * bounds.height()); if (node instanceof DebugComponent debugComponent) { - collectDebugComponents(debugComponent, mouseX, mouseY, info, depth, candidates); + collectDebugComponents(debugComponent, mouseX, mouseY, info, depth, bestCandidate); } if (node instanceof DebugFlowContainer flowContainer) { - collectFlowContent(flowContainer, mouseX, mouseY, info, depth, candidates); + collectFlowContent(flowContainer, mouseX, mouseY, info, depth, bestCandidate); } // Calculate cumulative scroll offset for children: parent's cumulative + this node's offset @@ -92,17 +87,17 @@ private void collectHoveredNodes(LytNode node, int mouseX, int mouseY, @Nullable } for (LytNode child : node.getChildren()) { - collectHoveredNodes(child, adjustedMouseX, adjustedMouseY, childParentInfo, candidates); + collectHoveredNodes(child, adjustedMouseX, adjustedMouseY, childParentInfo, depth + 1, bestCandidate); } } else if (bounds == null) { for (LytNode child : node.getChildren()) { - collectHoveredNodes(child, mouseX, mouseY, parentInfo, candidates); + collectHoveredNodes(child, mouseX, mouseY, parentInfo, depth + 1, bestCandidate); } } } private void collectDebugComponents(DebugComponent debugComponent, int mouseX, int mouseY, - HoveredElementInfo parentInfo, int parentDepth, List candidates) { + HoveredElementInfo parentInfo, int parentDepth, HoveredCandidate bestCandidate) { for (DebugComponent.ComponentEntry component : debugComponent.getDebugComponents()) { if (component.containsPoint(mouseX, mouseY)) { @@ -134,13 +129,13 @@ private void collectDebugComponents(DebugComponent debugComponent, int mouseX, i .width() * component.getBounds() .height(); - candidates.add(new HoveredCandidate(info, effectiveDepth, area)); + bestCandidate.consider(info, effectiveDepth, area); } } } private void collectFlowContent(DebugFlowContainer flowContainer, int mouseX, int mouseY, - HoveredElementInfo parentInfo, int parentDepth, List candidates) { + HoveredElementInfo parentInfo, int parentDepth, HoveredCandidate bestCandidate) { DebugFlowContainer.FlowContentEntry entry = flowContainer.pickFlowContent(mouseX, mouseY); if (entry != null && entry.bounds() @@ -171,36 +166,10 @@ private void collectFlowContent(DebugFlowContainer flowContainer, int mouseX, in .width() * entry.bounds() .height(); - candidates.add(new HoveredCandidate(info, parentDepth + 1, area)); + bestCandidate.consider(info, parentDepth + 1, area); } } - @Nullable - private HoveredElementInfo selectBestCandidate(List candidates) { - if (candidates.isEmpty()) { - return null; - } - - HoveredCandidate best = candidates.get(0); - for (HoveredCandidate candidate : candidates) { - if (candidate.depth > best.depth || (candidate.depth == best.depth && candidate.area < best.area)) { - best = candidate; - } - } - - return best.info; - } - - private int calculateDepth(LytNode node) { - int depth = 0; - LytNode current = node.getParent(); - while (current != null) { - depth++; - current = current.getParent(); - } - return depth; - } - private HoveredElementInfo createElementInfo(LytNode node, LytRect bounds, @Nullable HoveredElementInfo parentInfo) { String className = node.getClass() @@ -235,14 +204,17 @@ private void addBasicInfo(LytNode node, HoveredElementInfo info) { private static class HoveredCandidate { - final HoveredElementInfo info; - final int depth; - final float area; + @Nullable + private HoveredElementInfo info; + private int depth = Integer.MIN_VALUE; + private float area = Float.POSITIVE_INFINITY; - HoveredCandidate(HoveredElementInfo info, int depth, float area) { - this.info = info; - this.depth = depth; - this.area = area; + private void consider(HoveredElementInfo info, int depth, float area) { + if (depth > this.depth || depth == this.depth && area < this.area) { + this.info = info; + this.depth = depth; + this.area = area; + } } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/GuideDebugOverlay.java b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/GuideDebugOverlay.java index b15ece0d..6463949c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/debug/GuideDebugOverlay.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/debug/GuideDebugOverlay.java @@ -1,5 +1,7 @@ package com.hfstudio.guidenh.guide.internal.debug; +import java.util.List; + import net.minecraft.client.Minecraft; import net.minecraft.client.gui.FontRenderer; import net.minecraft.util.ChatComponentText; @@ -76,6 +78,30 @@ public void onFrameStart() { public void render(int screenWidth, int screenHeight, int mouseX, int mouseY, int contentX, int contentY, int contentW, int contentH, int scrollY, float zoom, @Nullable LytDocument document, FontRenderer fontRenderer) { + render( + screenWidth, + screenHeight, + mouseX, + mouseY, + contentX, + contentY, + contentW, + contentH, + scrollY, + zoom, + document, + List.of(), + fontRenderer); + } + + /** + * Render the debug overlay with document and screen-space component picking. + * + * @param screenComponents UI components whose bounds are already in screen coordinates + */ + public void render(int screenWidth, int screenHeight, int mouseX, int mouseY, int contentX, int contentY, + int contentW, int contentH, int scrollY, float zoom, @Nullable LytDocument document, + List screenComponents, FontRenderer fontRenderer) { if (!ModConfig.debug.guiDebugMode) { return; } @@ -95,16 +121,20 @@ public void render(int screenWidth, int screenHeight, int mouseX, int mouseY, in GL11.glColor4f(1.0f, 1.0f, 1.0f, 1.0f); GL11.glTranslatef(0.0F, 0.0F, OVERLAY_Z); - HoveredElementInfo hoveredInfo = null; + HoveredElementInfo documentHoveredInfo = null; if (document != null) { int docX = Math.round((mouseX - contentX) / zoom); int docY = Math.round((mouseY - contentY) / zoom) + scrollY; - hoveredInfo = hoverDetector.detectHoveredElement(document, docX, docY); + documentHoveredInfo = hoverDetector.detectHoveredElement(document, docX, docY); - if (hoveredInfo != null) { - adjustCoordinatesForRendering(hoveredInfo, contentX, contentY, scrollY, zoom); + if (documentHoveredInfo != null) { + adjustCoordinatesForRendering(documentHoveredInfo, contentX, contentY, scrollY, zoom); } } + ScreenComponentHit screenComponentHit = detectScreenComponent(screenComponents, mouseX, mouseY); + HoveredElementInfo hoveredInfo = screenComponentHit != null + && (documentHoveredInfo == null || screenComponentHit.priority() > 0) ? screenComponentHit.info() + : documentHoveredInfo; controlPanel.updatePosition(screenWidth, screenHeight); controlPanel.render(mouseX, mouseY, fontRenderer); @@ -118,6 +148,45 @@ public void render(int screenWidth, int screenHeight, int mouseX, int mouseY, in } } + @Nullable + private ScreenComponentHit detectScreenComponent(List components, int mouseX, + int mouseY) { + DebugComponent.ComponentEntry bestComponent = null; + int bestPriority = Integer.MIN_VALUE; + long bestArea = Long.MAX_VALUE; + for (DebugComponent.ComponentEntry component : components) { + if (!component.containsPoint(mouseX, mouseY)) { + continue; + } + var bounds = component.getBounds(); + long area = (long) bounds.width() * bounds.height(); + if (component.getPriority() > bestPriority || component.getPriority() == bestPriority && area < bestArea) { + bestComponent = component; + bestPriority = component.getPriority(); + bestArea = area; + } + } + if (bestComponent == null) { + return null; + } + var bounds = bestComponent.getBounds(); + HoveredElementInfo info = new HoveredElementInfo( + "ScreenComponent$" + bestComponent.getName(), + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + null); + info.setScreenCoordinates(bounds.x(), bounds.y(), bounds.width(), bounds.height()); + info.addExtraInfo("Component: " + bestComponent.getName()); + if (bestComponent.getExtraInfo() != null) { + info.addExtraInfo(bestComponent.getExtraInfo()); + } + return new ScreenComponentHit(info, bestPriority); + } + + private record ScreenComponentHit(HoveredElementInfo info, int priority) {} + /** * Adjust element coordinates from document space to screen space. * Applies cumulative scroll offsets from all ancestor scrollable containers. diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/gui/SceneEditorMultilineTextArea.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/gui/SceneEditorMultilineTextArea.java index 336d58bf..dc8bb6b5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/gui/SceneEditorMultilineTextArea.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/gui/SceneEditorMultilineTextArea.java @@ -16,6 +16,7 @@ import org.lwjgl.opengl.GL11; import com.hfstudio.guidenh.guide.compiler.GuideMarkdownOptions; +import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.internal.markdown.MdAstToMdxConverter; import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; @@ -113,6 +114,10 @@ public String paste() { }); } + public LytRect getBounds() { + return new LytRect(x, y, width, height); + } + public SceneEditorMultilineTextArea(FontRenderer fontRenderer, ClipboardAccess clipboardAccess) { this.fontRenderer = fontRenderer; this.clipboardAccess = clipboardAccess; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/io/SceneEditorOffscreenFramebuffer.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/io/SceneEditorOffscreenFramebuffer.java index e7bcc434..91b61ea7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/io/SceneEditorOffscreenFramebuffer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/io/SceneEditorOffscreenFramebuffer.java @@ -1,6 +1,7 @@ package com.hfstudio.guidenh.guide.internal.editor.io; import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; import java.nio.ByteBuffer; import net.minecraft.client.Minecraft; @@ -129,15 +130,18 @@ private BufferedImage readPixels() { GL11.glReadPixels(0, 0, width, height, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buffer); BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + int[] argbPixels = ((DataBufferInt) image.getRaster() + .getDataBuffer()).getData(); for (int y = 0; y < height; y++) { - int flippedY = height - 1 - y; + int sourceOffset = y * width * 4; + int targetOffset = (height - 1 - y) * width; for (int x = 0; x < width; x++) { - int index = (x + y * width) * 4; + int index = sourceOffset + x * 4; int r = buffer.get(index) & 0xFF; int g = buffer.get(index + 1) & 0xFF; int b = buffer.get(index + 2) & 0xFF; int a = buffer.get(index + 3) & 0xFF; - image.setRGB(x, flippedY, (a << 24) | (r << 16) | (g << 8) | b); + argbPixels[targetOffset + x] = a << 24 | r << 16 | g << 8 | b; } } return image; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java index 3ddf5ff4..344c5b55 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/host/scripts/SceneScript.java @@ -484,7 +484,7 @@ private static class ExceptionCollector implements LytErrorSink { @Override public void appendError(PageCompiler compiler, String text, UnistNode node) { - GuideDebugLog.warnAlways("[GuideNH] [SceneScript] {}", text); + GuideDebugLog.warn("[GuideNH] [SceneScript] {}", text); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/item/GuideDisplayItemStacks.java b/src/main/java/com/hfstudio/guidenh/guide/internal/item/GuideDisplayItemStacks.java index a68e5039..76b20bc9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/item/GuideDisplayItemStacks.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/item/GuideDisplayItemStacks.java @@ -174,9 +174,9 @@ private static String metaCacheKey(Item item, int meta) { private static void warnOnce(String key, String message, Object arg, @Nullable Throwable t) { if (WARNED_DISPLAY_FAILURES.add(key)) { if (t == null) { - GuideDebugLog.warnAlways(message, arg); + GuideDebugLog.warn(message, arg); } else { - GuideDebugLog.warnAlways(message, arg, t); + GuideDebugLog.warn(message, arg, t); } } } @@ -184,9 +184,9 @@ private static void warnOnce(String key, String message, Object arg, @Nullable T private static void warnOnce(String key, String message, Object arg1, Object arg2, @Nullable Throwable t) { if (WARNED_DISPLAY_FAILURES.add(key)) { if (t == null) { - GuideDebugLog.warnAlways(message, arg1, arg2); + GuideDebugLog.warn(message, arg1, arg2); } else { - GuideDebugLog.warnAlways(message, arg1, arg2, t); + GuideDebugLog.warn(message, arg1, arg2, t); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedFrontmatterMerger.java b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedFrontmatterMerger.java index d928a9bf..3b382f20 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedFrontmatterMerger.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedFrontmatterMerger.java @@ -22,7 +22,7 @@ public static String merge(String fallbackSource, String localizedSource) { long t1 = System.nanoTime(); long totalUs = (t1 - t0) / 1000; if (totalUs > 1_000) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [FrontmatterMerger] merge() took {}us, fallbackLen={} localizedLen={}", totalUs, fallbackSource.length(), @@ -64,7 +64,7 @@ private static String mergeInternal(String fallbackSource, String localizedSourc long t5 = System.nanoTime(); long totalUs = (t5 - t0) / 1000; if (totalUs > 1_000) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [FrontmatterMerger] mergeInternal normalize={}us split={}us yamlLoad={}us mergeKeys={}us writeMap={}us total={}us", (t1 - t0) / 1000, (t2 - t1) / 1000, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java index f6025a13..d830c1d5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideLocalizedPageSourceResolver.java @@ -46,7 +46,7 @@ public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, String lan long t2 = System.nanoTime(); long totalUs = (t2 - t0) / 1000; if (totalUs > 5_000) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [PageSourceResolver] parseFrontmatterOnly {} resolve={}us parse={}us total={}us", pageId, (t1 - t0) / 1000, @@ -82,7 +82,7 @@ public static ResolvedGuidePageSource resolve(String language, String contentRoo long t3 = System.nanoTime(); long totalUs = (t3 - t0) / 1000; if (totalUs > 2_000) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [PageSourceResolver] resolve {} i18nLookup={}us newString={}us merge={}us total={}us langKey={}", pageId, (t1 - t0) / 1000, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuidePageLanguageIndex.java b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuidePageLanguageIndex.java index 8bd0729c..b77cb9d3 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuidePageLanguageIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuidePageLanguageIndex.java @@ -84,7 +84,7 @@ private static Map loadLanguage(String normalizedLanguage) { loadResourcePackLanguage(resourcePack, normalizedLanguage, merged); long packNs = System.nanoTime() - packStartedAt; if (packNs > 100_000_000) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuidePageLanguageIndex] Slow resource pack [#{}/{}] {} took {} ms", packIndex, activeResourcePacks.size(), @@ -94,7 +94,7 @@ private static Map loadLanguage(String normalizedLanguage) { packIndex++; } long totalNs = System.nanoTime() - startedAt; - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuidePageLanguageIndex] Loaded {} page language keys for language {} from {} resource packs in {} ms", merged.size(), normalizedLanguage, @@ -165,10 +165,8 @@ private static void loadDirectoryLanguageEntries(File directory, String normaliz try (InputStream input = new FileInputStream(child)) { mergePageKeys(input, target); } catch (IOException e) { - GuideDebugLog.warnAlways( - "[GuideNH] [GuidePageLanguageIndex] Failed to read lang file {}", - child.getAbsolutePath(), - e); + GuideDebugLog + .warn("[GuideNH] [GuidePageLanguageIndex] Failed to read lang file {}", child.getAbsolutePath(), e); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideResourceLanguageIndex.java b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideResourceLanguageIndex.java index f8b25350..05f21b06 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideResourceLanguageIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/localization/GuideResourceLanguageIndex.java @@ -47,7 +47,7 @@ private static Map load(String normalizedLanguage) { loadResourcePackLanguage(resourcePack, normalizedLanguage, merged); long packNs = System.nanoTime() - packStartedAt; if (packNs > 100_000_000) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideResourceLanguageIndex] Slow resource pack [#{}/{}] {} took {} ms", packIndex, activeResourcePacks.size(), @@ -57,7 +57,7 @@ private static Map load(String normalizedLanguage) { packIndex++; } long totalNs = System.nanoTime() - startedAt; - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideResourceLanguageIndex] Loaded {} lang entries for language {} from {} resource packs in {} ms", merged.size(), normalizedLanguage, @@ -128,7 +128,7 @@ private static void loadDirectoryLanguageEntries(File directory, String normaliz try (InputStream input = new FileInputStream(child)) { target.putAll(StringTranslate.parseLangFile(input)); } catch (IOException e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideResourceLanguageIndex] Failed to read lang file {}", child.getAbsolutePath(), e); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java index 283bf732..82ef58ea 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/FileTreeCompiler.java @@ -61,12 +61,12 @@ private static LytBlock buildIconBlock(PageCompiler compiler, FileTreeIcon icon) var imageId = IdUtils.resolveLink(value, compiler.getPageId()); var imageContent = compiler.loadAsset(imageId); if (imageContent == null) { - GuideDebugLog.warnAlways("[GuideNH] [FileTreeCompiler] File tree iconPng not found: {}", value); + GuideDebugLog.warn("[GuideNH] [FileTreeCompiler] File tree iconPng not found: {}", value); image.setTitle("Missing image: " + value); } image.setImage(imageId, imageContent); } catch (IllegalArgumentException e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [FileTreeCompiler] File tree iconPng has invalid id '{}': {}", value, e.getMessage()); @@ -81,7 +81,7 @@ private static LytBlock buildIconBlock(PageCompiler compiler, FileTreeIcon icon) .getResourceDomain()); if (stack == null) { GuideDebugLog - .warnAlways("[GuideNH] [FileTreeCompiler] File tree iconItem could not be resolved: {}", value); + .warn("[GuideNH] [FileTreeCompiler] File tree iconItem could not be resolved: {}", value); LytParagraph fallback = new LytParagraph(); fallback.setMarginTop(0); fallback.setMarginBottom(0); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java index c1e4d7f0..2b738446 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/markdown/MarkdownRuntimeBlocks.java @@ -11,6 +11,8 @@ import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstBlockquote; +import com.hfstudio.guidenh.libs.mdast.model.MdAstParagraph; import com.hfstudio.guidenh.libs.mdast.model.MdAstText; public class MarkdownRuntimeBlocks { @@ -47,6 +49,16 @@ private MarkdownRuntimeBlocks() {} public static @Nullable BlockquoteDirective parseBlockquoteDirective(MdxJsxElementFields blockquote) { FirstParagraphText firstParagraph = findFirstParagraphText(blockquote); + return parseBlockquoteDirective(firstParagraph, blockquote.children()); + } + + public static @Nullable BlockquoteDirective parseBlockquoteDirective(MdAstBlockquote blockquote) { + FirstParagraphText firstParagraph = findFirstParagraphText(blockquote); + return parseBlockquoteDirective(firstParagraph, blockquote.children()); + } + + private static @Nullable BlockquoteDirective parseBlockquoteDirective(@Nullable FirstParagraphText firstParagraph, + List children) { if (firstParagraph == null) { return null; } @@ -67,7 +79,7 @@ private MarkdownRuntimeBlocks() {} new QuoteIconSpec(QuoteIconKind.TEXT, alertType.symbol()), trimLeadingDirectiveText(firstText, directiveEnd >= 0 ? directiveEnd + 1 : 0), firstParagraph.paragraph(), - blockquote.children()); + children); } String trimmed = firstText.trim(); @@ -118,14 +130,14 @@ private MarkdownRuntimeBlocks() {} icon, trimLeadingDirectiveText(trimmed, directiveEnd + 1), firstParagraph.paragraph(), - blockquote.children()); + children); } @Nullable private static FirstParagraphText findFirstParagraphText(MdxJsxElementFields blockquote) { for (Object child : blockquote.children()) { if (child instanceof MdxJsxFlowElement p && "p".equals(p.name())) { - String text = getLeadingParagraphText(p); + String text = getLeadingParagraphText(p.children()); if (text != null && !text.trim() .isEmpty()) { return new FirstParagraphText(p, text); @@ -141,8 +153,27 @@ private static FirstParagraphText findFirstParagraphText(MdxJsxElementFields blo } @Nullable - private static String getLeadingParagraphText(MdxJsxFlowElement paragraph) { - for (Object child : paragraph.children()) { + private static FirstParagraphText findFirstParagraphText(MdAstBlockquote blockquote) { + for (MdAstAnyContent child : blockquote.children()) { + if (child instanceof MdAstParagraph paragraph) { + String text = getLeadingParagraphText(paragraph.children()); + if (text != null && !text.trim() + .isEmpty()) { + return new FirstParagraphText(paragraph, text); + } + } else if (child instanceof MdAstText text) { + if (!text.value.trim() + .isEmpty()) { + return new FirstParagraphText(text, text.value); + } + } + } + return null; + } + + @Nullable + private static String getLeadingParagraphText(Iterable children) { + for (MdAstAnyContent child : children) { if (child instanceof MdAstText text) { if (!text.value.trim() .isEmpty()) { @@ -249,7 +280,7 @@ private static ColorValue parseColor(String value) { @Desugar public record BlockquoteDirective(@Nullable GithubAlertType alertType, ColorValue accentColor, @Nullable String title, @Nullable QuoteIconSpec icon, String remainingText, - @Nullable MdxJsxFlowElement firstParagraph, List children) {} + @Nullable MdAstAnyContent firstParagraph, List children) {} @Desugar public record QuoteIconSpec(QuoteIconKind kind, String value) {} @@ -261,7 +292,7 @@ public enum QuoteIconKind { } @Desugar - private record FirstParagraphText(@Nullable MdxJsxFlowElement paragraph, String text) {} + private record FirstParagraphText(@Nullable MdAstAnyContent paragraph, String text) {} @Desugar public record GithubAlertBlock(GithubAlertType type, List children, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java b/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java index 56900fbe..25d7ca9a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/LytNeiRecipeBox.java @@ -231,7 +231,7 @@ private void warnRecipeRenderFailure(Throwable t) { + t.getClass() .getName(); if (WARNED_RECIPE_RENDER_FAILURES.add(key)) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [LytNeiRecipeBox] Failed to render embedded recipe {}#{}; keeping recipe frame", handler.getClass() .getName(), diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/RecipeLookup.java b/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/RecipeLookup.java index c5fb6c52..cc095552 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/RecipeLookup.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/recipe/RecipeLookup.java @@ -121,9 +121,10 @@ public static ItemStack resolveOre(Object o) { if (o instanceof ItemStack stack) { return copy(stack); } - if (o instanceof Listlist) { - if (!list.isEmpty() && list.getFirst() instanceof ItemStack) { - return copy((ItemStack) list.getFirst()); + if (o instanceof Listlist && !list.isEmpty()) { + Object first = list.get(0); + if (first instanceof ItemStack stack) { + return copy(stack); } } return null; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/scene/GuidebookPreviewPlayerSkinResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/scene/GuidebookPreviewPlayerSkinResolver.java index 8bfab99e..2a3c1312 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/scene/GuidebookPreviewPlayerSkinResolver.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/scene/GuidebookPreviewPlayerSkinResolver.java @@ -33,6 +33,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import com.hfstudio.guidenh.client.GuideNhClientTaskScheduler; import com.hfstudio.guidenh.guide.scene.element.GuidebookSceneEntityLoader; import com.mojang.authlib.GameProfile; import com.mojang.authlib.minecraft.InsecureTextureException; @@ -104,8 +105,7 @@ static void queueSkinRefresh(GuidebookScenePreviewPlayerEntity entity) { public static void resolveSkinInBackground(String cacheKey, String playerName, GameProfile lookupProfile) { ResolvedPreviewPlayerSkin resolvedSkin = resolvePreviewPlayerSkinSafely(playerName, lookupProfile); - Minecraft minecraft = Minecraft.getMinecraft(); - minecraft.func_152344_a(() -> applyResolvedSkinOnMainThread(cacheKey, playerName, resolvedSkin)); + GuideNhClientTaskScheduler.execute(() -> applyResolvedSkinOnMainThread(cacheKey, playerName, resolvedSkin)); } public static void applyResolvedSkinOnMainThread(String cacheKey, String playerName, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/scheduler/SearchIndexWorkItem.java b/src/main/java/com/hfstudio/guidenh/guide/internal/scheduler/SearchIndexWorkItem.java index 09fe424b..215f4f6b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/scheduler/SearchIndexWorkItem.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/scheduler/SearchIndexWorkItem.java @@ -12,14 +12,17 @@ public Priority priority() { @Override public boolean shouldRun() { - return true; + return GuideME.getSearch() + .hasPendingWork(); } @Override public WorkResult tick(long deadlineNs) { - GuideME.getSearch() - .processWork(GuideSearch.BACKGROUND_TIME_PER_TICK); - return WorkResult.DONE; + GuideSearch search = GuideME.getSearch(); + long budget = search.isSearchPriorityActive() ? GuideSearch.SEARCH_TIME_PER_TICK + : GuideSearch.BACKGROUND_TIME_PER_TICK; + search.processWork(budget); + return WorkResult.YIELD; } @Override diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java b/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java index e228af36..858ca625 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/screen/GuideNavBar.java @@ -1,5 +1,6 @@ package com.hfstudio.guidenh.guide.internal.screen; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -26,6 +27,7 @@ import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.util.DisplayScale; import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; +import com.hfstudio.guidenh.guide.navigation.NavigationNode; import com.hfstudio.guidenh.guide.navigation.NavigationTree; import com.hfstudio.guidenh.guide.render.GuidePageTexture; @@ -155,9 +157,11 @@ public boolean shouldCreateNewPage() { public interface GuideExpansionListener { - void onExpansionToggled(@Nullable ResourceLocation guideId, ResourceLocation pageId, boolean expanded); + void onExpansionChanged(List changes, boolean allCollapsed); } + public record ExpansionChange(@Nullable ResourceLocation guideId, ResourceLocation pageId, boolean expanded) {} + private final List rows = new ArrayList<>(); private final Set expandedPageIds = new HashSet<>(); private final GuideNavProjection projection = new GuideNavProjection(); @@ -170,7 +174,7 @@ public interface GuideExpansionListener { private int lastExpandedStateHash; private boolean bookmarkGroupExpanded = true; @Nullable - private GuideExpansionListener onExpansionToggled; + private GuideExpansionListener onExpansionChanged; private int x; private int y; @@ -191,6 +195,7 @@ public void setBounds(int x, int y, int height) { this.x = x; this.y = y; this.height = height; + clampScrollToRows(); } public void setOpenWidth(int openWidth) { @@ -219,8 +224,8 @@ public void setContextMenuOpen(boolean contextMenuOpen) { } } - public void setOnExpansionToggled(@Nullable GuideExpansionListener listener) { - this.onExpansionToggled = listener; + public void setOnExpansionChanged(@Nullable GuideExpansionListener listener) { + this.onExpansionChanged = listener; } public void update(int mouseX, int mouseY, @Nullable NavigationTree tree, GuideBookmarkState bookmarkState) { @@ -330,6 +335,14 @@ public void expandParentsTo(@Nullable NavigationTree tree, @Nullable ResourceLoc } } + public void expandAll(@Nullable NavigationTree tree, GuideBookmarkState bookmarkState) { + updateExpansionState(collectExpandableNodes(tree), true, bookmarkState); + } + + public void collapseAll(@Nullable NavigationTree tree, GuideBookmarkState bookmarkState) { + updateExpansionState(collectExpandableNodes(tree), false, bookmarkState, true); + } + private boolean shouldRebuildRows(@Nullable NavigationTree tree, GuideBookmarkState bookmarkState) { return tree != lastTree || lastBookmarkStateVersion != bookmarkState.version() || lastExpandedStateHash != expandedPageIds.hashCode(); @@ -342,6 +355,7 @@ private void rebuildRows(@Nullable NavigationTree tree, GuideBookmarkState bookm if (tree == null) { lastBookmarkStateVersion = bookmarkState.version(); lastExpandedStateHash = expandedPageIds.hashCode(); + clampScrollToRows(); return; } GuideNavProjection.ProjectionResult projected = projection @@ -351,6 +365,7 @@ private void rebuildRows(@Nullable NavigationTree tree, GuideBookmarkState bookm } lastBookmarkStateVersion = bookmarkState.version(); lastExpandedStateHash = expandedPageIds.hashCode(); + clampScrollToRows(); } public void render(Minecraft mc, @Nullable ResourceLocation currentGuideId, @@ -640,7 +655,8 @@ private boolean renderRowTitle(Minecraft mc, FontRenderer fr, Row row, int textX @Nullable public ClickResult mouseClicked(int mouseX, int mouseY, @Nullable ResourceLocation currentGuideId, - @Nullable ResourceLocation currentPageId, GuideBookmarkState bookmarkState, boolean showNewPageButton) { + @Nullable ResourceLocation currentPageId, GuideBookmarkState bookmarkState, boolean showNewPageButton, + boolean shiftDown) { if (!isOpen()) { return null; } @@ -672,7 +688,11 @@ public ClickResult mouseClicked(int mouseX, int mouseY, @Nullable ResourceLocati } if (row.hasChildren() && isInsideExpandArrow(mouseX, row)) { - toggleExpand(row, bookmarkState); + if (shiftDown && row.kind() == GuideNavProjection.RowKind.TREE_PAGE) { + toggleExpandedDescendants(row, bookmarkState); + } else { + toggleExpand(row, bookmarkState); + } return ClickResult.none(); } @@ -795,16 +815,93 @@ private boolean isExpanded(Row row) { private void toggleExpand(Row row, GuideBookmarkState bookmarkState) { if (row.kind() == GuideNavProjection.RowKind.BOOKMARK_GROUP) { bookmarkGroupExpanded = !bookmarkGroupExpanded; - } else if (row.pageId() != null) { - if (expandedPageIds.contains(row.pageId())) { - expandedPageIds.remove(row.pageId()); - } else { - expandedPageIds.add(row.pageId()); + rebuildRows(lastTree, bookmarkState); + return; + } + ResourceLocation pageId = row.pageId(); + if (lastTree == null || pageId == null) { + return; + } + NavigationNode node = lastTree.getNodeById(pageId); + if (node == null) { + return; + } + updateExpansionState(List.of(node), !expandedPageIds.contains(pageId), bookmarkState); + } + + private void toggleExpandedDescendants(Row row, GuideBookmarkState bookmarkState) { + ResourceLocation pageId = row.pageId(); + if (lastTree == null || pageId == null) { + return; + } + NavigationNode node = lastTree.getNodeById(pageId); + if (node == null) { + return; + } + List nodes = collectExpandableNodes(node); + boolean allExpanded = !nodes.isEmpty() && nodes.stream() + .allMatch(expandableNode -> expandedPageIds.contains(expandableNode.pageId())); + updateExpansionState(nodes, !allExpanded, bookmarkState); + } + + private List collectExpandableNodes(@Nullable NavigationTree tree) { + return tree == null ? List.of() : collectExpandableNodes(tree.getRootNodes()); + } + + private List collectExpandableNodes(NavigationNode root) { + return collectExpandableNodes(List.of(root)); + } + + private List collectExpandableNodes(List roots) { + if (roots.isEmpty()) { + return List.of(); + } + List nodes = new ArrayList<>(); + ArrayDeque pendingNodes = new ArrayDeque<>(roots.size()); + for (int index = roots.size() - 1; index >= 0; index--) { + pendingNodes.push(roots.get(index)); + } + while (!pendingNodes.isEmpty()) { + NavigationNode node = pendingNodes.pop(); + List children = node.children(); + if (node.pageId() != null && !children.isEmpty()) { + nodes.add(node); + } + for (int index = children.size() - 1; index >= 0; index--) { + pendingNodes.push(children.get(index)); + } + } + return nodes; + } + + private void updateExpansionState(List nodes, boolean expanded, GuideBookmarkState bookmarkState) { + updateExpansionState(nodes, expanded, bookmarkState, false); + } + + private void updateExpansionState(List nodes, boolean expanded, GuideBookmarkState bookmarkState, + boolean allCollapsed) { + List changes = new ArrayList<>(); + boolean changed = allCollapsed && !expandedPageIds.isEmpty(); + for (NavigationNode node : nodes) { + ResourceLocation pageId = node.pageId(); + if (pageId == null) { + continue; + } + boolean nodeChanged = expanded ? expandedPageIds.add(pageId) : expandedPageIds.remove(pageId); + changed |= nodeChanged; + if (nodeChanged) { + changes.add(new ExpansionChange(node.guideId(), pageId, expanded)); } } + if (allCollapsed) { + expandedPageIds.clear(); + } + if (!changed) { + return; + } rebuildRows(lastTree, bookmarkState); - if (onExpansionToggled != null && row.pageId() != null) { - onExpansionToggled.onExpansionToggled(row.guideId(), row.pageId(), expandedPageIds.contains(row.pageId())); + if (onExpansionChanged != null) { + onExpansionChanged.onExpansionChanged(changes, allCollapsed); } } @@ -814,17 +911,23 @@ public boolean contains(int mouseX, int mouseY) { } public void scroll(int dwheel) { - int contentH = rows.size() * ROW_H + CONTENT_PADDING * 2; - int max = Math.max(0, contentH - Math.max(0, height - TITLE_H)); - scrollY -= Integer.signum(dwheel) * ROW_H * 2; - if (scrollY < 0) { - scrollY = 0; - } - if (scrollY > max) { - scrollY = max; + scrollY = Math.clamp(scrollY - Integer.signum(dwheel) * ROW_H * 2, 0, getMaxScrollY()); + } + + private void clampScrollToRows() { + int maxScrollY = getMaxScrollY(); + int clampedScrollY = Math.clamp(scrollY, 0, maxScrollY); + if (clampedScrollY != scrollY || visualScrollY.rounded() > maxScrollY) { + scrollY = clampedScrollY; + visualScrollY.snapTo(scrollY); } } + private int getMaxScrollY() { + int contentHeight = rows.size() * ROW_H + CONTENT_PADDING * 2; + return Math.max(0, contentHeight - getBodyHeight()); + } + private int getFirstVisibleRowIndex() { int firstVisibleRow = Math.floorDiv(visualScrollY.rounded() - CONTENT_PADDING - 1, ROW_H); return Math.clamp(firstVisibleRow, 0, rows.size()); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearch.java b/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearch.java index 971c140f..c942d1b0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearch.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearch.java @@ -2,14 +2,29 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.Deque; import java.util.HashSet; +import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.CompletionService; +import java.util.concurrent.ExecutorCompletionService; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import net.minecraft.util.ResourceLocation; @@ -35,8 +50,10 @@ import org.apache.lucene.search.highlight.Highlighter; import org.apache.lucene.search.highlight.InvalidTokenOffsetsException; import org.apache.lucene.search.highlight.QueryScorer; -import org.apache.lucene.store.ByteBuffersDirectory; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; import com.github.bsideup.jabel.Desugar; import com.hfstudio.guidenh.guide.Guide; @@ -50,52 +67,62 @@ import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.libs.unist.UnistNode; +import cpw.mods.fml.common.Loader; +import lombok.Getter; + /** - * Manages the in-memory Lucene index for guide search. + * Manages the persistent Lucene index for guide search. */ public class GuideSearch implements AutoCloseable { - /** Small background budget to avoid guide indexing from competing with gameplay-critical work. */ public static final long BACKGROUND_TIME_PER_TICK = TimeUnit.MILLISECONDS.toNanos(1); - /** Default budget used when indexing can make stronger forward progress. */ - public static final long DEFAULT_TIME_PER_TICK = TimeUnit.MILLISECONDS.toNanos(5); - - private final ByteBuffersDirectory directory = new ByteBuffersDirectory(); - + public static final long SEARCH_TIME_PER_TICK = TimeUnit.MILLISECONDS.toNanos(8); + private static final int INDEX_SCHEMA_VERSION = 1; + private static final String COMMIT_SCHEMA_VERSION = "guidenh.search.schema"; + private static final String COMMIT_FINGERPRINT = "guidenh.search.fingerprint"; + private static final long PUBLISH_INTERVAL_NANOS = TimeUnit.MILLISECONDS.toNanos(250); + private static final long SEARCH_PRIORITY_NANOS = TimeUnit.SECONDS.toNanos(1); + private static final int MAX_INDEX_WORKERS = 4; + + private Directory directory; private final Analyzer analyzer; - private final IndexWriter indexWriter; + private IndexWriter indexWriter; private IndexReader indexReader; private IndexSearcher indexSearcher; private final List pendingTasks = new ArrayList<>(); + private final ExecutorService indexingExecutor; + private final CompletionService completedDocuments; private Instant indexingStarted; private int pagesIndexed; + private int inFlightDocuments; + private long lastPublishedNanos; + private long searchPriorityUntilNanos; + @Getter + private long indexRevision; + private long buildGeneration; private final Set warnedAboutLanguage = Collections.synchronizedSet(new HashSet<>()); private final Set indexedLanguages = Collections.synchronizedSet(new HashSet<>()); public GuideSearch() { analyzer = new LanguageSpecificAnalyzerWrapper(); - ClassLoader prevCCL = Thread.currentThread() - .getContextClassLoader(); - Thread.currentThread() - .setContextClassLoader(GuideSearch.class.getClassLoader()); - try { - var config = new IndexWriterConfig(analyzer); - indexWriter = new IndexWriter(directory, config); - // Commit once so DirectoryReader can open the in-memory index immediately. - indexWriter.flush(); - indexWriter.commit(); - indexReader = DirectoryReader.open(directory); - indexSearcher = new IndexSearcher(indexReader); - } catch (IOException e) { - // ByteBuffersDirectory keeps this in memory, so initialization failures are unexpected. - throw new UncheckedIOException("Failed to create index writer.", e); - } finally { - Thread.currentThread() - .setContextClassLoader(prevCCL); - } + int workerCount = Math.clamp( + Runtime.getRuntime() + .availableProcessors() - 1, + 1, + MAX_INDEX_WORKERS); + indexingExecutor = Executors.newFixedThreadPool(workerCount, runnable -> { + Thread thread = new Thread(runnable, "GuideNH Search Indexer"); + thread.setDaemon(true); + return thread; + }); + completedDocuments = new ExecutorCompletionService<>(indexingExecutor); } public void index(Guide guide) { + if (indexWriter == null) { + indexAll(); + return; + } try { indexWriter.deleteDocuments( new Term( @@ -113,19 +140,38 @@ public void index(Guide guide) { pendingTasks.removeIf( t -> t.guide.getId() .equals(guide.getId())); - pendingTasks.add(new GuideIndexingTask(guide, new ArrayList<>(guide.getPages()))); + pendingTasks.add(new GuideIndexingTask(guide, new ArrayDeque<>(guide.getPages()))); } public void indexAll() { - pendingTasks.clear(); + String fingerprint = fingerprint(GuideRegistry.getAll()); + cancelPendingWork(); indexedLanguages.clear(); warnedAboutLanguage.clear(); try { - indexWriter.deleteAll(); - indexWriter.flush(); + closeIndex(); + directory = openDirectory(fingerprint); + if (DirectoryReader.indexExists(directory)) { + indexReader = DirectoryReader.open(directory); + if (matchesFingerprint(indexReader, fingerprint)) { + indexWriter = openIndexWriter(IndexWriterConfig.OpenMode.APPEND); + indexSearcher = new IndexSearcher(indexReader); + indexedLanguages.addAll(Analyzers.MINECRAFT_TO_LUCENE_LANG.values()); + indexRevision++; + GuideDebugLog.info("[GuideNH] [GuideSearch] Loaded persistent search index {}", fingerprint); + return; + } + indexReader.close(); + indexReader = null; + } + + indexWriter = openIndexWriter(IndexWriterConfig.OpenMode.CREATE); + indexWriter.setLiveCommitData(commitData(fingerprint).entrySet()); indexWriter.commit(); - refreshIndexReader(); + indexReader = DirectoryReader.open(directory); + indexSearcher = new IndexSearcher(indexReader); + indexRevision++; } catch (IOException e) { throw new UncheckedIOException("Failed to reset the guide search index.", e); } @@ -136,75 +182,129 @@ public void indexAll() { } public void processWork() { - processWork(DEFAULT_TIME_PER_TICK); + processWork(BACKGROUND_TIME_PER_TICK); } public void processWork(long budgetNanos) { - if (pendingTasks.isEmpty()) { + if (indexWriter == null || !hasPendingWork()) { return; } long start = System.nanoTime(); - - var guideTaskIt = pendingTasks.iterator(); - while (guideTaskIt.hasNext()) { - if (isTimeElapsed(start, budgetNanos)) { - return; + schedulePendingPages(); + boolean wroteDocuments = false; + while (!isTimeElapsed(start, budgetNanos)) { + Future completed = completedDocuments.poll(); + if (completed == null) { + break; + } + PageIndexDocument pageDocument = receiveCompletedDocument(completed); + if (pageDocument == null || pageDocument.generation() != buildGeneration) { + continue; } + inFlightDocuments--; + writeDocument(pageDocument); + wroteDocuments = true; + schedulePendingPages(); + } - var guideTask = guideTaskIt.next(); - var guide = guideTask.guide(); + boolean finished = !hasPendingWork(); + if (wroteDocuments && (finished || isPublishDue())) { + publishIndex(); + } + if (finished) { + GuideDebugLog.info( + "[GuideNH] [GuideSearch] Indexing of {} pages finished in {}", + pagesIndexed, + Duration.between(indexingStarted, Instant.now())); + } + } - var pageIt = guideTask.pendingPages.iterator(); - while (pageIt.hasNext()) { - if (isTimeElapsed(start, budgetNanos)) { - return; - } + private boolean isTimeElapsed(long start, long budgetNanos) { + return System.nanoTime() - start >= budgetNanos; + } - var page = pageIt.next(); + public boolean hasPendingWork() { + return !pendingTasks.isEmpty() || inFlightDocuments > 0; + } - var pageDoc = createPageDocument(guideTask.guide(), page); - if (pageDoc != null) { - try { - indexWriter.addDocument(pageDoc); - } catch (IOException e) { - GuideDebugLog.error("[GuideNH] [GuideSearch] Failed to index document {}{}", guide, page, e); - } + public boolean isSearchPriorityActive() { + return System.nanoTime() < searchPriorityUntilNanos; + } - var searchLang = pageDoc.get(IndexSchema.FIELD_SEARCH_LANG); - if (searchLang != null) { - indexedLanguages.add(searchLang); - } - } - pagesIndexed++; - pageIt.remove(); + private void schedulePendingPages() { + int maximumInFlight = MAX_INDEX_WORKERS * 2; + while (inFlightDocuments < maximumInFlight && !pendingTasks.isEmpty()) { + GuideIndexingTask task = pendingTasks.getFirst(); + var page = task.pendingPages() + .pollFirst(); + if (page == null) { + pendingTasks.removeFirst(); + continue; } - - guideTaskIt.remove(); + long generation = buildGeneration; + completedDocuments.submit(() -> createPageIndexDocument(task.guide(), page, generation)); + inFlightDocuments++; } + } + @Nullable + private PageIndexDocument receiveCompletedDocument(Future completed) { try { - indexWriter.flush(); - indexWriter.commit(); - refreshIndexReader(); - } catch (IOException e) { - throw new UncheckedIOException(e); + return completed.get(); + } catch (Exception exception) { + GuideDebugLog.error("[GuideNH] [GuideSearch] Failed to prepare a search document", exception); + return null; } + } - GuideDebugLog.info( - "[GuideNH] [GuideSearch] Indexing of {} pages finished in {}", - pagesIndexed, - Duration.between(indexingStarted, Instant.now())); + @NonNull + private PageIndexDocument createPageIndexDocument(Guide guide, ParsedGuidePage page, long generation) { + try { + Document document = createPageDocument(guide, page); + return new PageIndexDocument(guide, page, document, generation); + } catch (Throwable throwable) { + GuideDebugLog + .error("[GuideNH] [GuideSearch] Failed to prepare index document {}{}", guide, page, throwable); + return new PageIndexDocument(guide, page, null, generation); + } } - public void processAllWork() { - while (!pendingTasks.isEmpty()) { - processWork(DEFAULT_TIME_PER_TICK); + private void writeDocument(PageIndexDocument pageDocument) { + try { + Document document = pageDocument.document(); + if (document == null) { + return; + } + indexWriter.addDocument(document); + String searchLanguage = document.get(IndexSchema.FIELD_SEARCH_LANG); + if (searchLanguage != null) { + indexedLanguages.add(searchLanguage); + } + } catch (IOException exception) { + GuideDebugLog.error( + "[GuideNH] [GuideSearch] Failed to index document {}{}", + pageDocument.guide(), + pageDocument.page(), + exception); + } finally { + pagesIndexed++; } } - private boolean isTimeElapsed(long start, long budgetNanos) { - return System.nanoTime() - start >= budgetNanos; + private boolean isPublishDue() { + return System.nanoTime() - lastPublishedNanos >= PUBLISH_INTERVAL_NANOS; + } + + private void publishIndex() { + try { + indexWriter.commit(); + refreshIndexReader(); + lastPublishedNanos = System.nanoTime(); + indexRevision++; + } catch (IOException exception) { + throw new UncheckedIOException("Failed to publish the guide search index.", exception); + } } private void refreshIndexReader() throws IOException { @@ -212,16 +312,18 @@ private void refreshIndexReader() throws IOException { var oldReader = indexReader; indexReader = newReader; indexSearcher = new IndexSearcher(newReader); - oldReader.close(); + if (oldReader != null) { + oldReader.close(); + } } public List searchGuide(String queryText, @Nullable Guide onlyFromGuide) { if (queryText.isEmpty()) { return List.of(); } - - if (!pendingTasks.isEmpty()) { - processAllWork(); + searchPriorityUntilNanos = System.nanoTime() + SEARCH_PRIORITY_NANOS; + if (indexSearcher == null) { + return List.of(); } var searchLanguage = getLuceneLanguageFromMinecraft(LangUtil.getCurrentLanguage()); @@ -268,7 +370,7 @@ public List searchGuide(String queryText, @Nullable Guide onlyFrom var guide = GuideRegistry.getById(guideId); if (guide == null) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideSearch] Search index produced guide id {} which couldn't be found.", guideId); continue; @@ -276,7 +378,7 @@ public List searchGuide(String queryText, @Nullable Guide onlyFrom var page = guide.getParsedPage(pageId); if (page == null) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideSearch] Search index produced page {} in guide {}, which couldn't be found.", pageId, guideId); @@ -373,7 +475,7 @@ private String getLuceneLanguageFromMinecraft(String language) { var luceneLang = Analyzers.MINECRAFT_TO_LUCENE_LANG.get(language); if (luceneLang == null) { if (warnedAboutLanguage.add(language)) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideSearch] Minecraft language '{}' is unknown, so search falls back to english.", language); } @@ -405,38 +507,153 @@ public void appendBreak() { return searchableText.toString(); } - @Override - public void close() throws IOException { - IOException suppressed = null; + private Directory openDirectory(String fingerprint) throws IOException { + Path indexDirectory = Loader.instance() + .getConfigDir() + .toPath() + .resolve("guidenh") + .resolve("search-index") + .resolve("v" + INDEX_SCHEMA_VERSION) + .resolve(fingerprint); + Files.createDirectories(indexDirectory); + return FSDirectory.open(indexDirectory); + } + + private IndexWriter openIndexWriter(IndexWriterConfig.OpenMode openMode) throws IOException { + ClassLoader previousClassLoader = Thread.currentThread() + .getContextClassLoader(); + Thread.currentThread() + .setContextClassLoader(GuideSearch.class.getClassLoader()); try { - indexWriter.close(); - } catch (IOException e) { - suppressed = e; + return new IndexWriter(directory, new IndexWriterConfig(analyzer).setOpenMode(openMode)); + } finally { + Thread.currentThread() + .setContextClassLoader(previousClassLoader); } - try { - indexReader.close(); - } catch (IOException e) { - if (suppressed != null) { - suppressed.addSuppressed(e); - } else { - suppressed = e; + } + + private boolean matchesFingerprint(IndexReader reader, String fingerprint) throws IOException { + if (!(reader instanceof DirectoryReader directoryReader)) { + return false; + } + Map commitData = directoryReader.getIndexCommit() + .getUserData(); + return Integer.toString(INDEX_SCHEMA_VERSION) + .equals(commitData.get(COMMIT_SCHEMA_VERSION)) && fingerprint.equals(commitData.get(COMMIT_FINGERPRINT)); + } + + private Map commitData(String fingerprint) { + return Map.of(COMMIT_SCHEMA_VERSION, Integer.toString(INDEX_SCHEMA_VERSION), COMMIT_FINGERPRINT, fingerprint); + } + + private String fingerprint(Iterable guides) { + MessageDigest digest = createFingerprintDigest(); + updateFingerprint(digest, "schema=" + INDEX_SCHEMA_VERSION); + var sortedGuides = new ArrayList(); + for (Guide guide : guides) { + sortedGuides.add(guide); + } + sortedGuides.sort( + Comparator.comparing( + guide -> guide.getId() + .toString())); + for (Guide guide : sortedGuides) { + updateFingerprint( + digest, + guide.getId() + .toString()); + var pages = new ArrayList<>(guide.getPages()); + pages.sort( + Comparator.comparing( + page -> page.getId() + .toString())); + for (var page : pages) { + updateFingerprint( + digest, + page.getId() + .toString()); + updateFingerprint(digest, page.getLanguage()); + updateFingerprint(digest, page.getSource()); } } + return HexFormat.of() + .formatHex(digest.digest()); + } + + private MessageDigest createFingerprintDigest() { try { - directory.close(); - } catch (IOException e) { - if (suppressed != null) { - e.addSuppressed(suppressed); + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private void updateFingerprint(MessageDigest digest, String value) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + } + + private void cancelPendingWork() { + buildGeneration++; + pendingTasks.clear(); + inFlightDocuments = 0; + } + + private void closeIndex() throws IOException { + IOException failure = null; + if (indexWriter != null) { + try { + indexWriter.close(); + } catch (IOException exception) { + failure = exception; + } finally { + indexWriter = null; + } + } + if (indexReader != null) { + try { + indexReader.close(); + } catch (IOException exception) { + if (failure != null) { + failure.addSuppressed(exception); + } else { + failure = exception; + } + } finally { + indexReader = null; + indexSearcher = null; + } + } + if (directory != null) { + try { + directory.close(); + } catch (IOException exception) { + if (failure != null) { + failure.addSuppressed(exception); + } else { + failure = exception; + } + } finally { + directory = null; } - throw e; } - if (suppressed != null) { - throw suppressed; + if (failure != null) { + throw failure; } } + @Override + public void close() throws IOException { + cancelPendingWork(); + indexingExecutor.shutdownNow(); + closeIndex(); + } + + @Desugar + public record GuideIndexingTask(Guide guide, Deque pendingPages) {} + @Desugar - record GuideIndexingTask(Guide guide, List pendingPages) {} + public record PageIndexDocument(Guide guide, ParsedGuidePage page, Document document, long generation) {} @Desugar public record SearchResult(ResourceLocation guideId, ResourceLocation pageId, String pageTitle, LytFlowContent text, diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java b/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java index fbd34e6c..568475d7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/search/GuideSearchSnippetFormatter.java @@ -1,8 +1,12 @@ package com.hfstudio.guidenh.guide.internal.search; +import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Deque; import java.util.List; +import org.jetbrains.annotations.Nullable; + import com.github.bsideup.jabel.Desugar; import com.hfstudio.guidenh.guide.document.DefaultStyles; import com.hfstudio.guidenh.guide.document.block.LytVisitor; @@ -22,7 +26,102 @@ public static LytFlowContent format(String fragmentMarkup) { var expanded = expandHighlightMarkup(fragmentMarkup == null ? "" : fragmentMarkup); var plain = new StringBuilder(); var ranges = parseRanges(expanded, plain); - return buildFlowContent(plain.toString(), ranges); + var sanitized = stripMarkdownDelimiters(plain.toString(), ranges); + return buildFlowContent(sanitized.text(), sanitized.ranges()); + } + + private static SanitizedSnippet stripMarkdownDelimiters(String text, List ranges) { + var delimiters = findMarkdownDelimiters(text); + if (delimiters.isEmpty()) { + return new SanitizedSnippet(text, ranges); + } + + int[] offsets = new int[text.length() + 1]; + var sanitized = new StringBuilder(text.length()); + int sourceIndex = 0; + while (sourceIndex < text.length()) { + offsets[sourceIndex] = sanitized.length(); + IntRange delimiter = delimiterStartingAt(delimiters, sourceIndex); + if (delimiter != null) { + for (int index = delimiter.startInclusive() + 1; index <= delimiter.endExclusive(); index++) { + offsets[index] = sanitized.length(); + } + sourceIndex = delimiter.endExclusive(); + continue; + } + sanitized.append(text.charAt(sourceIndex)); + sourceIndex++; + offsets[sourceIndex] = sanitized.length(); + } + + var sanitizedRanges = new ArrayList(ranges.size()); + for (var range : ranges) { + int start = offsets[range.startInclusive()]; + int end = offsets[range.endExclusive()]; + if (start < end) { + sanitizedRanges.add(new IntRange(start, end)); + } + } + return new SanitizedSnippet(sanitized.toString(), sanitizedRanges); + } + + private static List findMarkdownDelimiters(String text) { + var delimiters = new ArrayList(); + Deque openDelimiters = new ArrayDeque<>(); + int index = 0; + while (index < text.length()) { + if (!isMarkdownDelimiter(text.charAt(index)) || isEscaped(text, index)) { + index++; + continue; + } + int end = index + 1; + while (end < text.length() && text.charAt(end) == text.charAt(index)) { + end++; + } + String token = text.substring(index, end); + MarkdownDelimiter opening = openDelimiters.peek(); + if (opening != null && opening.token() + .equals(token) && containsVisibleText(text, opening.endExclusive(), index)) { + delimiters.add(new IntRange(opening.startInclusive(), opening.endExclusive())); + delimiters.add(new IntRange(index, end)); + openDelimiters.pop(); + } else { + openDelimiters.push(new MarkdownDelimiter(token, index, end)); + } + index = end; + } + return delimiters; + } + + private static boolean isMarkdownDelimiter(char character) { + return !Character.isLetterOrDigit(character) && !Character.isWhitespace(character); + } + + private static boolean isEscaped(String text, int index) { + int slashCount = 0; + for (int cursor = index - 1; cursor >= 0 && text.charAt(cursor) == '\\'; cursor--) { + slashCount++; + } + return slashCount % 2 != 0; + } + + private static boolean containsVisibleText(String text, int start, int end) { + for (int index = start; index < end; index++) { + if (!Character.isWhitespace(text.charAt(index))) { + return true; + } + } + return false; + } + + @Nullable + private static IntRange delimiterStartingAt(List delimiters, int index) { + for (var delimiter : delimiters) { + if (delimiter.startInclusive() == index) { + return delimiter; + } + } + return null; } public static LytFlowContent clipToVisibleChars(LytFlowContent content, int maxVisibleChars) { @@ -227,4 +326,10 @@ public record IntRange(int startInclusive, int endExclusive) { } } } + + @Desugar + private record SanitizedSnippet(String text, List ranges) {} + + @Desugar + private record MarkdownDelimiter(String token, int startInclusive, int endExclusive) {} } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/search/PageIndexer.java b/src/main/java/com/hfstudio/guidenh/guide/internal/search/PageIndexer.java index 065a5774..64b3fe1a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/search/PageIndexer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/search/PageIndexer.java @@ -18,6 +18,7 @@ import com.hfstudio.guidenh.libs.mdast.MdAstYamlFrontmatter; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstBreak; import com.hfstudio.guidenh.libs.mdast.model.MdAstDefinition; import com.hfstudio.guidenh.libs.mdast.model.MdAstRoot; import com.hfstudio.guidenh.libs.mdast.model.MdAstText; @@ -60,13 +61,15 @@ public void index(MdAstRoot root, IndexingSink sink) { public void indexContent(MdAstAnyContent content, IndexingSink sink) { if (content instanceof MdAstText astText) { sink.appendText(astText, astText.value); + } else if (content instanceof MdAstBreak) { + sink.appendBreak(); } else if (content instanceof MdxJsxElementFields el) { + if ("br".equals(el.name())) { + sink.appendBreak(); + return; + } var compiler = tagCompilers.get(el.name()); if (compiler == null) { - GuideDebugLog.warnAlways( - "[GuideNH] [PageIndexer] Unhandled MDX element in guide search indexing: {}", - el.name()); - // Fallback: index children content indexContent(el.children(), sink); } else { compiler.index(this, el, sink); @@ -75,7 +78,7 @@ public void indexContent(MdAstAnyContent content, IndexingSink sink) { // Handled via conversion } else { GuideDebugLog - .warnAlways("[GuideNH] [PageIndexer] Unhandled node type in guide search indexing: {}", content.type()); + .warn("[GuideNH] [PageIndexer] Unhandled node type in guide search indexing: {}", content.type()); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java index a0f8e8e7..7495fdc4 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/latex/GuideLatexRenderer.java @@ -55,8 +55,7 @@ public int calibrateRefHeight(float sourceScale) { int h = icon.getIconHeight(); return Math.max(1, h); } catch (ParseException e) { - GuideDebugLog - .warnAlways("[GuideNH/LaTeX] Failed to calibrate reference height for scale {}", sourceScale, e); + GuideDebugLog.warn("[GuideNH/LaTeX] Failed to calibrate reference height for scale {}", sourceScale, e); return 16; } }); @@ -120,11 +119,11 @@ public int[] measureSize(String formula, int fillColorArgb, float sourceScale) { GuideLatexTextureCache.INSTANCE.putSize(sizeKey, w, h, d); return new int[] { w, h, d }; } catch (ParseException e) { - GuideDebugLog.warnAlways("[GuideNH/LaTeX] Parse error measuring '{}': {}", formula, e.getMessage()); + GuideDebugLog.warn("[GuideNH/LaTeX] Parse error measuring '{}': {}", formula, e.getMessage()); GuideLatexTextureCache.INSTANCE.markFailed(formula, e.getMessage()); return null; } catch (Exception e) { - GuideDebugLog.warnAlways("[GuideNH/LaTeX] Unexpected error measuring '{}': {}", formula, e.getMessage(), e); + GuideDebugLog.warn("[GuideNH/LaTeX] Unexpected error measuring '{}': {}", formula, e.getMessage(), e); GuideLatexTextureCache.INSTANCE.markFailed(formula, e.getMessage()); return null; } @@ -175,11 +174,11 @@ public int[] getOrCreateTexture(String formula, int fillColorArgb, float sourceS return new int[] { textureId, w, h }; } catch (ParseException e) { - GuideDebugLog.warnAlways("[GuideNH/LaTeX] Parse error rendering '{}': {}", formula, e.getMessage()); + GuideDebugLog.warn("[GuideNH/LaTeX] Parse error rendering '{}': {}", formula, e.getMessage()); GuideLatexTextureCache.INSTANCE.markFailed(formula, e.getMessage()); return null; } catch (Exception e) { - GuideDebugLog.warnAlways("[GuideNH/LaTeX] Unexpected error rendering '{}': {}", formula, e.getMessage(), e); + GuideDebugLog.warn("[GuideNH/LaTeX] Unexpected error rendering '{}': {}", formula, e.getMessage(), e); GuideLatexTextureCache.INSTANCE.markFailed( formula, e.getMessage() == null ? e.getClass() diff --git a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiCategoryParser.java b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiCategoryParser.java index d1a1675f..a33970d6 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiCategoryParser.java +++ b/src/main/java/com/hfstudio/guidenh/guide/mediawiki/MediaWikiCategoryParser.java @@ -87,7 +87,7 @@ private static MediaWikiCategoryReference parseReference(ParsedGuidePage page, O } private static void warnMalformedCategories(ParsedGuidePage page, String message) { - GuideDebugLog.warnAlways("[GuideNH] [MediaWikiCategoryParser] Page {} {}", page.getId(), message); + GuideDebugLog.warn("[GuideNH] [MediaWikiCategoryParser] Page {} {}", page.getId(), message); } private static String normalizeCategoryKey(String categoryName) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/navigation/NavigationTree.java b/src/main/java/com/hfstudio/guidenh/guide/navigation/NavigationTree.java index 1f1afe15..8c152043 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/navigation/NavigationTree.java +++ b/src/main/java/com/hfstudio/guidenh/guide/navigation/NavigationTree.java @@ -327,7 +327,13 @@ public static NavigationNode createNode(Map no public static final Comparator NODE_COMPARATOR = Comparator.comparingInt(NavigationNode::position) .reversed() - .thenComparing(NavigationNode::title); + .thenComparing(NavigationNode::title) + .thenComparing( + node -> node.guideId() != null ? node.guideId() + .toString() : "") + .thenComparing( + node -> node.pageId() != null ? node.pageId() + .toString() : ""); @Nullable private static NavigationNode createMergedNode(Map nodeIndex, diff --git a/src/main/java/com/hfstudio/guidenh/guide/render/GuidePageTexture.java b/src/main/java/com/hfstudio/guidenh/guide/render/GuidePageTexture.java index f131e598..9408b7d1 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/render/GuidePageTexture.java +++ b/src/main/java/com/hfstudio/guidenh/guide/render/GuidePageTexture.java @@ -121,12 +121,12 @@ public static String sanitize(String raw) { private static LytSize readImageSize(ResourceLocation id, byte[] imageData) throws Exception { try (ImageInputStream input = ImageIO.createImageInputStream(new ByteArrayInputStream(imageData))) { if (input == null) { - GuideDebugLog.warnAlways("Failed to inspect image {} (ImageIO returned null stream)", id); + GuideDebugLog.warn("Failed to inspect image {} (ImageIO returned null stream)", id); return null; } Iterator readers = ImageIO.getImageReaders(input); if (!readers.hasNext()) { - GuideDebugLog.warnAlways("Failed to inspect image {} (no ImageIO reader found)", id); + GuideDebugLog.warn("Failed to inspect image {} (no ImageIO reader found)", id); return null; } ImageReader reader = readers.next(); @@ -162,7 +162,7 @@ public ResourceLocation getTexture() { BufferedImage img = ImageIO.read(new ByteArrayInputStream(data)); if (img == null) { GuideDebugLog - .warnAlways("Failed to decode image {} while creating dynamic texture (ImageIO returned null)", id); + .warn("Failed to decode image {} while creating dynamic texture (ImageIO returned null)", id); imageData = null; return null; } @@ -206,10 +206,8 @@ private static ITextureObject removeTextureObject(TextureManager textureManager, TEXTURE_OBJECTS_SRG_FIELD); return textureObjects.remove(location); } catch (Throwable t) { - GuideDebugLog.warnAlways( - "Failed to remove dynamic guide page texture {} from Minecraft texture manager", - location, - t); + GuideDebugLog + .warn("Failed to remove dynamic guide page texture {} from Minecraft texture manager", location, t); return null; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java index bea94881..66211928 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/GuidebookLevelRenderer.java @@ -221,7 +221,11 @@ public void render(GuidebookLevel level, CameraSettings camera, int panelX, int int glScissorH = (cy1 - cy0) * sf; try (var env = GuidebookFakeRenderEnvironment.enter(level, camera, partialTicks)) { - level.prepareForPreview(); + if (GuideSiteSceneTessellatorCapture.getActive() != null) { + level.prepareForStaticExport(); + } else { + level.prepareForPreview(); + } GL11.glPushAttrib( GL11.GL_ENABLE_BIT | GL11.GL_COLOR_BUFFER_BIT @@ -299,7 +303,17 @@ public void render(GuidebookLevel level, CameraSettings camera, int panelX, int renderWeatherInContext(level, camera, layerSelection, weatherEffects, weatherAnimationTick); } if (!particles.isEmpty()) { - renderParticlesInContext(particles, partialTicks); + GuideSiteSceneTessellatorCapture capture = GuideSiteSceneTessellatorCapture.getActive(); + if (capture != null) { + capture.setCapturingParticles(true); + } + try { + renderParticlesInContext(particles, partialTicks); + } finally { + if (capture != null) { + capture.setCapturingParticles(false); + } + } } } finally { mc.entityRenderer.disableLightmap(partialTicks); @@ -484,9 +498,7 @@ private void renderEntities(Iterable entities, float partialTicks, OpenGlHelper .setLightmapTextureCoords(OpenGlHelper.lightmapTexUnit, (float) lowerBits, (float) upperBits); GL11.glColor4f(1f, 1f, 1f, 1f); - // Our guidebook camera already supplies world-space transforms, so pass raw - // interpolated coordinates here instead of letting RenderManager subtract the - // preview player position a second time via renderEntityStatic(). + // The scene view matrix already transforms world coordinates relative to the camera. renderManager.renderEntityWithPosYaw( entity, renderState.x(), diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/element/GuidebookSceneEntityLoader.java b/src/main/java/com/hfstudio/guidenh/guide/scene/element/GuidebookSceneEntityLoader.java index 22f0b2d9..edfeecca 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/element/GuidebookSceneEntityLoader.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/element/GuidebookSceneEntityLoader.java @@ -150,8 +150,6 @@ static Entity load(@Nullable World world, String entityId, @Nullable NBTTagCompo public static Set buildEntityIdCandidates(String entityId) { LinkedHashSet candidates = new LinkedHashSet<>(); - addCandidateForms(candidates, entityId); - String normalized = entityId.toLowerCase(Locale.ROOT); int namespaceSeparator = normalized.indexOf(':'); if (namespaceSeparator >= 0) { @@ -190,7 +188,7 @@ public static void appendRegisteredEntityMatches(Set candidates, String String normalizedRegisteredId = normalizeEntityId(registeredId); if (normalizedInput.equals(normalizedRegisteredId)) { - addCandidateForms(candidates, registeredId); + candidates.add(registeredId); continue; } @@ -200,7 +198,7 @@ public static void appendRegisteredEntityMatches(Set candidates, String } if (uniqueSimpleMatches.size() == 1) { - addCandidateForms(candidates, uniqueSimpleMatches.getFirst()); + candidates.add(uniqueSimpleMatches.getFirst()); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java b/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java index 227b21c2..0133052e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java +++ b/src/main/java/com/hfstudio/guidenh/guide/scene/level/GuidebookLevel.java @@ -126,6 +126,18 @@ public void prepareForPreview() { GuidePreviewStateSupport.prepare(this); } + /** + * Prepares render state for a static export without advancing the fake world's entities. + */ + public void prepareForStaticExport() { + if (!previewStateDirty) { + return; + } + previewStateDirty = false; + rebindAllTileEntities(); + GuidePreviewStateSupport.prepare(this); + } + public void setBlock(int x, int y, int z, @Nullable Block block, int meta, @Nullable TileEntity tileEntity) { if (y < 0 || y >= 256) return; diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportOptions.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportOptions.java index 14e60e50..7c99a716 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportOptions.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportOptions.java @@ -2,7 +2,7 @@ public class GuideSiteExportOptions { - public static final GuideSiteExportOptions DEFAULT = new GuideSiteExportOptions(false); + public static final GuideSiteExportOptions DEFAULT = new GuideSiteExportOptions(true); private final boolean exportPonderEveryTick; diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportTask.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportTask.java index 53011980..5c57e481 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportTask.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteExportTask.java @@ -14,6 +14,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; import net.minecraft.client.Minecraft; import net.minecraft.client.resources.IResourceManager; @@ -33,11 +35,15 @@ import com.hfstudio.guidenh.guide.PageCollection; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytNode; import com.hfstudio.guidenh.guide.indices.CategoryIndex; import com.hfstudio.guidenh.guide.indices.PageIndex; import com.hfstudio.guidenh.guide.internal.GuideRegistry; import com.hfstudio.guidenh.guide.internal.GuidebookText; import com.hfstudio.guidenh.guide.internal.MutableGuide; +import com.hfstudio.guidenh.guide.internal.host.LytHost; +import com.hfstudio.guidenh.guide.internal.host.scripts.SceneScript; import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; import com.hfstudio.guidenh.guide.internal.util.LangUtil; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiListContext; @@ -49,6 +55,7 @@ import com.hfstudio.guidenh.guide.scene.LytGuidebookScene.PonderTimelineKeyframe; import com.hfstudio.guidenh.guide.scene.SceneBlockStatsEntry; import com.hfstudio.guidenh.guide.scene.SceneSoundCue; +import com.hfstudio.guidenh.guide.scene.SceneTagCompiler.ScenePlaceholder; import com.hfstudio.guidenh.guide.scene.StructureLibSceneBinding; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.sound.GuideSoundSpec; @@ -60,7 +67,12 @@ public class GuideSiteExportTask { public static final Gson GSON = new GsonBuilder().disableHtmlEscaping() .serializeNulls() .create(); - private static final int MAX_SCENE_STATE_VARIANTS = 256; + private static final int MAX_SCENE_STRUCTURE_TIER = 4; + private static final int MAX_SCENE_STRUCTURE_CHANNEL_VALUE = 4; + private static final int MAX_SCENE_STATE_VARIANTS = 125; + private static final long SCENE_MATERIALIZATION_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(30); + private static final long SCENE_MATERIALIZATION_STEP_NANOS = TimeUnit.MILLISECONDS.toNanos(2); + private static final long SCENE_MATERIALIZATION_WAIT_NANOS = TimeUnit.MILLISECONDS.toNanos(1); private final Path outDir; private final GuideSiteExportOptions options; @@ -213,6 +225,7 @@ public Result run() throws Exception { GuideSiteTemplateRegistry templates = new GuideSiteTemplateRegistry(); GuidePage compiledPage = PageCompiler .compile(scopedGuide, scopedGuide.getExtensions(), variant.parsedPage()); + materializeScenes(scopedGuide, compiledPage); List exportedScenes = exportScenes( guide, variant.parsedPage(), @@ -307,6 +320,7 @@ public Result run() throws Exception { } } finally { restoreMinecraftLanguage(originalMcLanguage); + sceneExporter.close(); } for (Map.Entry>> entry : searchEntriesByLanguage.entrySet()) { @@ -790,6 +804,47 @@ private List exportScenes(MutableGuide guide, ParsedGuid return htmlScenes; } + private void materializeScenes(Guide guide, GuidePage compiledPage) { + LytDocument document = compiledPage.document(); + if (!containsScenePlaceholder(document)) { + return; + } + + LytHost host = new LytHost(); + SceneScript sceneScript = new SceneScript(); + host.setCurrentPageCollection(guide); + host.setCurrentPageId( + compiledPage.id() + .toString()); + host.registerScript("Scene", sceneScript); + host.registerScript("GameScene", sceneScript); + host.mountDocument(document); + + long timeoutAt = System.nanoTime() + SCENE_MATERIALIZATION_TIMEOUT_NANOS; + while (host.hasWork() && System.nanoTime() < timeoutAt) { + host.step(System.nanoTime() + SCENE_MATERIALIZATION_STEP_NANOS); + if (host.hasWork()) { + LockSupport.parkNanos(SCENE_MATERIALIZATION_WAIT_NANOS); + } + } + host.mountDocument(null); + if (containsScenePlaceholder(document)) { + throw new IllegalStateException("Timed out while materializing GameScene for " + compiledPage.id()); + } + } + + private boolean containsScenePlaceholder(LytNode node) { + if (node instanceof ScenePlaceholder) { + return true; + } + for (LytNode child : node.getChildren()) { + if (containsScenePlaceholder(child)) { + return true; + } + } + return false; + } + private GuideSiteExportedScene exportScene(ParsedGuidePage parsedPage, LytGuidebookScene scene, GuideSiteTemplateRegistry templates, GuideSiteAssetRegistry assets, GuideSiteSceneRuntimeExporter exporter, GuideSitePageAssetExporter assetExporter, GuideSiteItemIconResolver itemIconResolver) throws Exception { @@ -799,7 +854,8 @@ private GuideSiteExportedScene exportScene(ParsedGuidePage parsedPage, LytGuideb templates, exporter, assetExporter, - itemIconResolver); + itemIconResolver, + true); if (baseScene == null) { return null; } @@ -835,15 +891,16 @@ private GuideSiteExportedScene exportScene(ParsedGuidePage parsedPage, LytGuideb private GuideSiteExportedScene exportSceneState(ParsedGuidePage parsedPage, LytGuidebookScene scene, GuideSiteTemplateRegistry templates, GuideSiteSceneRuntimeExporter exporter, - GuideSitePageAssetExporter assetExporter, GuideSiteItemIconResolver itemIconResolver) throws Exception { + GuideSitePageAssetExporter assetExporter, GuideSiteItemIconResolver itemIconResolver, + boolean includePlaceholder) throws Exception { scene.getLevel() - .prepareForPreview(); + .prepareForStaticExport(); GuideSiteSceneAnnotationSerializer.AnnotationPayload annotationPayload = GuideSiteSceneAnnotationSerializer .serialize(scene, templates, parsedPage.getId(), assetExporter, itemIconResolver); String hoverTargetsJson = GuideSiteSceneHoverTargetSerializer .serialize(scene, templates, parsedPage.getId(), assetExporter, itemIconResolver); String sceneSoundsJson = serializeSceneSounds(scene, parsedPage.getId(), assetExporter); - GuideSiteExportedScene runtimeExport = exporter.exportScene(scene); + GuideSiteExportedScene runtimeExport = exporter.exportScene(scene, includePlaceholder); return new GuideSiteExportedScene( runtimeExport.placeholderPath(), runtimeExport.scenePath(), @@ -1040,7 +1097,7 @@ private String exportSceneStateManifest(ParsedGuidePage parsedPage, LytGuidebook GuideSitePageAssetExporter assetExporter, GuideSiteItemIconResolver itemIconResolver, GuideSiteExportedScene baseScene) throws Exception { SceneStateManifestPlan plan = buildSceneStateManifestPlan(scene); - if (plan == null || (plan.states.size() <= 1 && !plan.controls.containsKey("ponder"))) { + if (plan == null) { return null; } @@ -1063,7 +1120,18 @@ private String exportSceneStateManifest(ParsedGuidePage parsedPage, LytGuidebook templates, exporter, assetExporter, - itemIconResolver); + itemIconResolver, + false); + exportedVariant = new GuideSiteExportedScene( + baseScene.placeholderPath(), + exportedVariant.scenePath(), + exportedVariant.logicalWidth(), + exportedVariant.logicalHeight(), + exportedVariant.inWorldJson(), + exportedVariant.overlayJson(), + exportedVariant.hoverTargetsJson(), + exportedVariant.sceneSoundsJson(), + null); } if (exportedVariant == null) { return null; @@ -1086,7 +1154,7 @@ private String exportSceneStateManifest(ParsedGuidePage parsedPage, LytGuidebook } private SceneStateManifestPlan buildSceneStateManifestPlan(LytGuidebookScene scene) { - if (scene == null || (!scene.isInteractive() && !scene.hasPonderData())) { + if (scene == null) { return null; } @@ -1094,33 +1162,33 @@ private SceneStateManifestPlan buildSceneStateManifestPlan(LytGuidebookScene sce List ponderKeyframes = scene.hasPonderData() ? scene.getPonderTimelineKeyframesForExport() : List.of(); - List ponderTicks = buildPonderTickStates(scene, ponderKeyframes); List structurePlans = buildStructureStatePlans(scene); - long variantCount = (long) visibleLayers.size() * (long) ponderTicks.size(); + long staticVariantCount = visibleLayers.size(); for (StructureStatePlan structurePlan : structurePlans) { - variantCount *= structurePlan.states.size(); - if (variantCount > MAX_SCENE_STATE_VARIANTS) { - warnSceneStateVariantLimit(variantCount); + staticVariantCount = multiplySceneVariantCounts(staticVariantCount, structurePlan.stateCount()); + if (staticVariantCount > MAX_SCENE_STATE_VARIANTS) { + warnSceneStateVariantLimit(staticVariantCount); return null; } } + int ponderStateBudget = (int) Math.max(1L, MAX_SCENE_STATE_VARIANTS / staticVariantCount); + List ponderTicks = buildPonderTickStates(scene, ponderKeyframes, ponderStateBudget); + long variantCount = multiplySceneVariantCounts(staticVariantCount, ponderTicks.size()); if (variantCount > MAX_SCENE_STATE_VARIANTS) { warnSceneStateVariantLimit(variantCount); return null; } - if (variantCount <= 1 && !scene.hasPonderData()) { - return null; + for (StructureStatePlan structurePlan : structurePlans) { + structurePlan.materializeStates(); } LinkedHashMap controls = new LinkedHashMap<>(); - if (visibleLayers.size() > 1) { - LinkedHashMap visibleLayerControl = new LinkedHashMap<>(); - visibleLayerControl.put("label", GuidebookText.SceneVisibleLayerLabel.text()); - visibleLayerControl.put("allLabel", GuidebookText.SceneAll.text()); - visibleLayerControl.put("max", visibleLayers.getLast()); - controls.put("visibleLayer", visibleLayerControl); - } + LinkedHashMap visibleLayerControl = new LinkedHashMap<>(); + visibleLayerControl.put("label", GuidebookText.SceneVisibleLayerLabel.text()); + visibleLayerControl.put("allLabel", GuidebookText.SceneAll.text()); + visibleLayerControl.put("max", visibleLayers.getLast()); + controls.put("visibleLayer", visibleLayerControl); if (scene.hasPonderData() && !ponderTicks.isEmpty()) { LinkedHashMap ponderControl = new LinkedHashMap<>(); ponderControl.put("label", "Ponder"); @@ -1236,8 +1304,7 @@ private Map serializeSceneVariant(GuideSiteExportedScene exporte } private List buildVisibleLayerStates(LytGuidebookScene scene) { - if (scene == null || !scene.isVisibleLayerSliderEnabled() - || scene.getLevel() == null + if (scene == null || scene.getLevel() == null || scene.getLevel() .isEmpty()) { return List.of(scene != null ? scene.getCurrentVisibleLayer() : 0); @@ -1254,26 +1321,47 @@ private List buildVisibleLayerStates(LytGuidebookScene scene) { return layers; } - private List buildPonderTickStates(LytGuidebookScene scene, List ponderKeyframes) { + private List buildPonderTickStates(LytGuidebookScene scene, List ponderKeyframes, + int stateBudget) { if (scene == null || !scene.hasPonderData()) { return List.of(0); } - if (options.exportPonderEveryTick()) { - int totalTime = Math.max(0, scene.getPonderTotalTimeForExport()); + int totalTime = Math.max(0, scene.getPonderTotalTimeForExport()); + int maxStates = Math.max(1, stateBudget); + if (options.exportPonderEveryTick() && totalTime + 1 <= maxStates) { ArrayList states = new ArrayList<>(totalTime + 1); for (int tick = 0; tick <= totalTime; tick++) { states.add(tick); } return states.isEmpty() ? List.of(0) : states; } - ArrayList states = new ArrayList<>(ponderKeyframes.size() + 2); + ArrayList states = new ArrayList<>(maxStates); addUniquePonderTickState(states, scene.getPonderCurrentTickForExport()); + if (states.size() < maxStates) { + addUniquePonderTickState(states, 0); + } + if (states.size() < maxStates) { + addUniquePonderTickState(states, totalTime); + } + if (!options.exportPonderEveryTick()) { + for (PonderTimelineKeyframe keyframe : ponderKeyframes) { + if (!keyframe.isHidden() && states.size() < maxStates) { + addUniquePonderTickState(states, keyframe.getTime()); + } + } + return states.isEmpty() ? List.of(0) : states; + } + for (PonderTimelineKeyframe keyframe : ponderKeyframes) { - if (!keyframe.isHidden()) { + if (!keyframe.isHidden() && states.size() < maxStates) { addUniquePonderTickState(states, keyframe.getTime()); } } - addUniquePonderTickState(states, scene.getPonderTotalTimeForExport()); + int sampledTickCount = Math.max(0, maxStates - states.size()); + for (int sample = 1; sample <= sampledTickCount; sample++) { + addUniquePonderTickState(states, Math.round((float) sample * totalTime / (sampledTickCount + 1))); + } + states.sort(Integer::compareTo); return states.isEmpty() ? List.of(0) : states; } @@ -1304,7 +1392,7 @@ private List buildStructureStatePlans(LytGuidebookScene scen tiers, selectableChannels, channelIds, - buildStructureVariantStates(tiers, channelIds, channelValues))); + channelValues)); } return plans; } @@ -1326,34 +1414,6 @@ private String buildStructureControlLabel(StructureLibSceneBinding binding, return "Structure"; } - private List buildStructureVariantStates(List tiers, List channelIds, - List> channelValues) { - ArrayList states = new ArrayList<>(); - appendStructureVariantStates(states, tiers, channelIds, channelValues, 0, new ArrayList<>()); - return states.isEmpty() - ? List.of(new StructureVariantState(StructureLibPreviewSelection.DEFAULT_MASTER_TIER, null)) - : states; - } - - private void appendStructureVariantStates(List states, List tiers, - List channelIds, List> channelValues, int channelIndex, List currentChannels) { - if (channelIndex >= channelValues.size()) { - for (Integer tier : tiers) { - LinkedHashMap channelState = new LinkedHashMap<>(channelIds.size()); - for (int i = 0; i < channelIds.size(); i++) { - channelState.put(channelIds.get(i), currentChannels.get(i)); - } - states.add(new StructureVariantState(tier, channelState)); - } - return; - } - for (Integer value : channelValues.get(channelIndex)) { - currentChannels.add(value); - appendStructureVariantStates(states, tiers, channelIds, channelValues, channelIndex + 1, currentChannels); - currentChannels.removeLast(); - } - } - private void addUniquePonderTickState(List states, int tick) { int normalized = Math.max(0, tick); if (!states.contains(normalized)) { @@ -1377,9 +1437,12 @@ private List buildTierStates(StructureLibSceneBinding binding, Structur .of(binding != null ? binding.getCurrentTier() : StructureLibPreviewSelection.DEFAULT_MASTER_TIER); } ArrayList tiers = new ArrayList<>(); + int maxTier = Math.min( + metadata.getTierData() + .getMaxValue(), + MAX_SCENE_STRUCTURE_TIER); for (int tier = metadata.getTierData() - .getMinValue(); tier <= metadata.getTierData() - .getMaxValue(); tier++) { + .getMinValue(); tier <= maxTier; tier++) { tiers.add(tier); } return tiers; @@ -1402,13 +1465,25 @@ private List buildChannelStates(StructureLibSceneMetadata.ChannelData c if (channelData == null || !channelData.isSelectable()) { return List.of(0); } - ArrayList states = new ArrayList<>(channelData.getMaxValue() + 1); - for (int value = channelData.getMinValue(); value <= channelData.getMaxValue(); value++) { + int maxValue = Math.min(channelData.getMaxValue(), MAX_SCENE_STRUCTURE_CHANNEL_VALUE); + ArrayList states = new ArrayList<>(maxValue + 1); + for (int value = channelData.getMinValue(); value <= maxValue; value++) { states.add(value); } return states; } + private long multiplySceneVariantCounts(long left, long right) { + if (left <= 0 || right <= 0) { + return 0; + } + if (left > MAX_SCENE_STATE_VARIANTS || right > MAX_SCENE_STATE_VARIANTS + || left > MAX_SCENE_STATE_VARIANTS / right) { + return MAX_SCENE_STATE_VARIANTS + 1L; + } + return left * right; + } + private GuideSiteHtmlCompiler.SceneResolver createSceneResolver(List exportedScenes) { return new GuideSiteHtmlCompiler.SceneResolver() { @@ -1495,16 +1570,21 @@ private String key() { .append(visibleLayer) .append("|ponder=") .append(ponderTick); - for (Map.Entry structureEntry : structures.entrySet()) { + ArrayList structureIds = new ArrayList<>(structures.keySet()); + structureIds.sort(String::compareTo); + for (String structureId : structureIds) { + StructureVariantState structureState = structures.get(structureId); key.append("|structure:") - .append(structureEntry.getKey()) + .append(structureId) .append("|tier=") - .append(structureEntry.getValue().tier); - for (Map.Entry channelEntry : structureEntry.getValue().channels.entrySet()) { + .append(structureState.tier); + ArrayList channelIds = new ArrayList<>(structureState.channels.keySet()); + channelIds.sort(String::compareTo); + for (String channelId : channelIds) { key.append("|channel:") - .append(channelEntry.getKey()) + .append(channelId) .append("=") - .append(channelEntry.getValue()); + .append(structureState.channels.get(channelId)); } } return key.toString(); @@ -1552,19 +1632,57 @@ private static final class StructureStatePlan { private final List tiers; private final List selectableChannels; private final List channelIds; - private final List states; + private final List> channelValues; + private List states = List.of(); private StructureStatePlan(String bindingKey, @Nullable String structureName, String label, List tiers, List selectableChannels, List channelIds, - List states) { + List> channelValues) { this.bindingKey = bindingKey; this.structureName = structureName; this.label = label; this.tiers = tiers != null ? new ArrayList<>(tiers) : List.of(); this.selectableChannels = selectableChannels != null ? new ArrayList<>(selectableChannels) : List.of(); this.channelIds = channelIds != null ? new ArrayList<>(channelIds) : List.of(); - this.states = states != null ? new ArrayList<>(states) - : List.of(new StructureVariantState(StructureLibPreviewSelection.DEFAULT_MASTER_TIER, null)); + this.channelValues = channelValues != null ? new ArrayList<>(channelValues) : List.of(); + } + + private long stateCount() { + long count = Math.max(1, tiers.size()); + for (List values : channelValues) { + int valueCount = values != null ? values.size() : 0; + if (valueCount <= 0 || count > MAX_SCENE_STATE_VARIANTS / valueCount) { + return MAX_SCENE_STATE_VARIANTS + 1L; + } + count *= valueCount; + } + return count; + } + + private void materializeStates() { + ArrayList materialized = new ArrayList<>((int) stateCount()); + appendStates(materialized, 0, new ArrayList<>()); + states = materialized.isEmpty() + ? List.of(new StructureVariantState(StructureLibPreviewSelection.DEFAULT_MASTER_TIER, null)) + : materialized; + } + + private void appendStates(List out, int channelIndex, List currentChannels) { + if (channelIndex >= channelValues.size()) { + for (Integer tier : tiers) { + LinkedHashMap channelState = new LinkedHashMap<>(channelIds.size()); + for (int i = 0; i < channelIds.size(); i++) { + channelState.put(channelIds.get(i), currentChannels.get(i)); + } + out.add(new StructureVariantState(tier, channelState)); + } + return; + } + for (Integer value : channelValues.get(channelIndex)) { + currentChannels.add(value); + appendStates(out, channelIndex + 1, currentChannels); + currentChannels.removeLast(); + } } private boolean hasControls() { @@ -1584,12 +1702,20 @@ private Map toControlMap() { } if (!selectableChannels.isEmpty()) { ArrayList> channelControls = new ArrayList<>(selectableChannels.size()); - for (StructureLibSceneMetadata.ChannelData channelData : selectableChannels) { + for (int i = 0; i < selectableChannels.size(); i++) { + StructureLibSceneMetadata.ChannelData channelData = selectableChannels.get(i); LinkedHashMap channelControl = new LinkedHashMap<>(); channelControl.put("id", channelData.getChannelId()); channelControl.put("label", channelData.getLabel()); - channelControl.put("min", channelData.getMinValue()); - channelControl.put("max", channelData.getMaxValue()); + channelControl.put( + "min", + channelValues.get(i) + .getFirst()); + channelControl.put( + "max", + channelValues.get(i) + .getLast()); + channelControl.put("values", new ArrayList<>(channelValues.get(i))); channelControl.put("unsetLabel", GuidebookText.SceneNotSet.text()); channelControls.add(channelControl); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java index cbec8c7a..69c2d801 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteHtmlCompiler.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -26,9 +27,7 @@ import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.BlockquoteDirective; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.QuoteIconSpec; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidDiagramType; -import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartDocument; import com.hfstudio.guidenh.guide.internal.mermaid.flowchart.FlowchartParser; -import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapDocument; import com.hfstudio.guidenh.guide.internal.mermaid.mindmap.MindmapParser; import com.hfstudio.guidenh.guide.sound.GuideSoundSpec; import com.hfstudio.guidenh.guide.sound.GuideSoundTrigger; @@ -38,6 +37,7 @@ import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxTextElement; import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstBlockquote; import com.hfstudio.guidenh.libs.mdast.model.MdAstParent; import com.hfstudio.guidenh.libs.mdast.model.MdAstText; @@ -53,6 +53,11 @@ default String render(MdxJsxElementFields element, String defaultNamespace) { return render(recipeId, fallbackText, defaultNamespace); } + default String render(MdxJsxElementFields element, String defaultNamespace, + GuideSiteTemplateRegistry templates) { + return render(element, defaultNamespace); + } + String render(String recipeId, String fallbackText, String defaultNamespace); } @@ -279,6 +284,9 @@ private String compileNode(MdAstAnyContent node, GuideSiteTemplateRegistry templ currentPageId, sceneResolver); } + if (node instanceof MdAstBlockquote blockquote) { + return compileBlockquoteMarkdown(blockquote, templates, defaultNamespace, currentPageId, sceneResolver); + } if (node instanceof MdAstParent) { return compileChildren( ((MdAstParent) node).children(), @@ -408,12 +416,13 @@ private String compileCustomFlowElement(MdxJsxFlowElement flowElement, GuideSite if (isTooltipElement(flowElement)) return "

" + compileTooltip(flowElement, templates, defaultNamespace, currentPageId, sceneResolver) + "

"; - if (isRecipeElement(flowElement)) return compileRecipe(flowElement, defaultNamespace); + if (isRecipeElement(flowElement)) return compileRecipe(flowElement, defaultNamespace, templates); if (isSceneElement(flowElement)) return compileScene(flowElement, templates, defaultNamespace, currentPageId, sceneResolver); if (isFloatingImageElement(flowElement)) return compileFloatingImage(flowElement, templates, defaultNamespace, currentPageId, sceneResolver); - if (isLatexElement(flowElement)) return compileLatex(flowElement, true, templates); + if (isLatexElement(flowElement)) + return compileLatex(flowElement, true, templates, defaultNamespace, currentPageId, sceneResolver); String rendered = mdxTagRenderer .render(flowElement, defaultNamespace, currentPageId, templates, sceneResolver, this); if (rendered != null) return rendered; @@ -427,12 +436,13 @@ private String compileCustomTextElement(MdxJsxTextElement textElement, GuideSite if (isHtmlBreakElement(textElement)) return compileHtmlBreak(textElement); if (isTooltipElement(textElement)) return compileTooltip(textElement, templates, defaultNamespace, currentPageId, sceneResolver); - if (isRecipeElement(textElement)) return compileRecipe(textElement, defaultNamespace); + if (isRecipeElement(textElement)) return compileRecipe(textElement, defaultNamespace, templates); if (isSceneElement(textElement)) return compileScene(textElement, templates, defaultNamespace, currentPageId, sceneResolver); if (isFloatingImageElement(textElement)) return compileFloatingImage(textElement, templates, defaultNamespace, currentPageId, sceneResolver); - if (isLatexElement(textElement)) return compileLatex(textElement, false, templates); + if (isLatexElement(textElement)) + return compileLatex(textElement, false, templates, defaultNamespace, currentPageId, sceneResolver); String rendered = mdxTagRenderer .render(textElement, defaultNamespace, currentPageId, templates, sceneResolver, this); if (rendered != null) return rendered; @@ -445,7 +455,7 @@ private String compileParagraph(MdxJsxElementFields el, GuideSiteTemplateRegistr String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { String displayFormula = extractSoleDisplayLatexFromElement(el); if (displayFormula != null) { - return renderLatex(displayFormula, null, 1.0f, 100.0f, false, null, 0, 0, true, templates); + return renderLatex(displayFormula, null, 1.0f, 100.0f, false, null, null, 0, 0, true, templates); } return "

" + compileChildren(el.children(), templates, defaultNamespace, currentPageId, sceneResolver) + "

"; @@ -478,36 +488,82 @@ private String compileBlockquoteMdx(MdxJsxElementFields el, GuideSiteTemplateReg + ""; } + private String compileBlockquoteMarkdown(MdAstBlockquote blockquote, GuideSiteTemplateRegistry templates, + String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { + BlockquoteDirective directive = MarkdownRuntimeBlocks.parseBlockquoteDirective(blockquote); + if (directive != null && directive.alertType() != null) { + return compileAlertBoxMdx(directive, templates, defaultNamespace, currentPageId, sceneResolver); + } + if (directive != null) { + return compileQuoteBoxMdx(directive, templates, defaultNamespace, currentPageId, sceneResolver); + } + return "
" + + compileChildren(blockquote.children(), templates, defaultNamespace, currentPageId, sceneResolver) + + "
"; + } + private String compileAlertBoxMdx(BlockquoteDirective directive, GuideSiteTemplateRegistry templates, String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { - String body = compileChildren(directive.children(), templates, defaultNamespace, currentPageId, sceneResolver); - String typeName = directive.alertType() - .displayText(); - return "
" + escapeHtml(typeName) + "" + body + "
"; + return compileQuoteBoxMdx(directive, templates, defaultNamespace, currentPageId, sceneResolver); } private String compileQuoteBoxMdx(BlockquoteDirective directive, GuideSiteTemplateRegistry templates, String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { - String body = compileChildren(directive.children(), templates, defaultNamespace, currentPageId, sceneResolver); - StringBuilder html = new StringBuilder("
"); - if (directive.title() != null) { - html.append("") - .append(escapeHtml(directive.title())) - .append("
"); + if (directive.title() != null || directive.icon() != null) { + html.append("
"); + if (directive.icon() != null) { + html.append( + renderQuoteIcon(directive.icon(), templates, defaultNamespace, currentPageId, sceneResolver)); + } + if (directive.title() != null) { + html.append("") + .append(escapeHtml(directive.title())) + .append(""); + } + html.append("
"); } - html.append(body) + html.append("
") + .append(body) + .append("
") .append("
"); return html.toString(); } + private String compileDirectiveBody(BlockquoteDirective directive, GuideSiteTemplateRegistry templates, + String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { + StringBuilder body = new StringBuilder(); + boolean directiveHandled = false; + for (MdAstAnyContent child : directive.children()) { + if (child == directive.firstParagraph() || directive.firstParagraph() == null && !directiveHandled) { + directiveHandled = true; + if (hasText(directive.remainingText())) { + body.append("

") + .append(compileText(directive.remainingText(), templates, defaultNamespace, currentPageId)) + .append("

"); + } + continue; + } + body.append(compileChildren(List.of(child), templates, defaultNamespace, currentPageId, sceneResolver)); + } + return body.toString(); + } + private String compileListMdx(MdxJsxElementFields el, GuideSiteTemplateRegistry templates, String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { String tag = "ol".equals(el.name()) ? "ol" : "ul"; @@ -554,24 +610,7 @@ private String compileCodeBlockMdx(MdxJsxElementFields el, GuideSiteTemplateRegi return rendered != null ? rendered : GuideSiteGraphRenderer.renderFileTree(codeText); } if ("mermaid".equals(lang)) { - try { - MermaidDiagramType type = MermaidDiagramType.detect(codeText); - switch (type) { - case MINDMAP -> { - MindmapDocument doc = MindmapParser.parse(codeText); - return GuideSiteGraphRenderer.renderMermaidTree(doc); - } - case FLOWCHART -> { - FlowchartDocument doc = FlowchartParser.parse(codeText); - return GuideSiteGraphRenderer.renderFlowchart(doc); - } - case UNKNOWN -> { - return CODE_BLOCK_RENDERER.render("mermaid", codeText, width, height); - } - } - } catch (Exception ignored) { - return CODE_BLOCK_RENDERER.render("mermaid", codeText, width, height); - } + return renderMermaidCodeBlock(codeText, lang, width, height); } if ("funcgraph".equals(lang) || "functiongraph".equals(lang)) { try { @@ -585,6 +624,19 @@ private String compileCodeBlockMdx(MdxJsxElementFields el, GuideSiteTemplateRegi return CODE_BLOCK_RENDERER.render(lang, codeText, width, height); } + private String renderMermaidCodeBlock(String codeText, String language, @Nullable Integer width, + @Nullable Integer height) { + try { + return switch (MermaidDiagramType.detect(codeText)) { + case FLOWCHART -> GuideSiteGraphRenderer.renderFlowchart(FlowchartParser.parse(codeText), Map.of()); + case MINDMAP -> GuideSiteGraphRenderer.renderMermaidTree(MindmapParser.parse(codeText), Map.of()); + case UNKNOWN -> CODE_BLOCK_RENDERER.render(language, codeText, width, height); + }; + } catch (IllegalArgumentException ignored) { + return CODE_BLOCK_RENDERER.render(language, codeText, width, height); + } + } + @Nullable private static Integer parseMetaInt(String meta, String key) { if (meta == null || meta.isEmpty()) { @@ -833,8 +885,9 @@ private boolean isLatexElement(MdxJsxElementFields element) { return "Latex".equals(element.name()); } - private String compileRecipe(MdxJsxElementFields element, String defaultNamespace) { - return recipeTagRenderer.render(element, defaultNamespace); + private String compileRecipe(MdxJsxElementFields element, String defaultNamespace, + GuideSiteTemplateRegistry templates) { + return recipeTagRenderer.render(element, defaultNamespace, templates); } private String compileSpoiler(MdxJsxElementFields element, GuideSiteTemplateRegistry templates, @@ -1173,7 +1226,8 @@ private String buildFloatingImageHtml(String src, String alt, @Nullable String t return html.toString(); } - private String compileLatex(MdxJsxElementFields element, boolean display, GuideSiteTemplateRegistry templates) { + private String compileLatex(MdxJsxElementFields element, boolean display, GuideSiteTemplateRegistry templates, + String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { String formula = element.getAttributeString("formula", null); if (formula == null || formula.trim() .isEmpty()) { @@ -1182,7 +1236,8 @@ private String compileLatex(MdxJsxElementFields element, boolean display, GuideS String color = element.getAttributeString("color", null); float scale = parseFloat(element.getAttributeString("scale", null), 1.0f); float sourceScale = parseFloat(element.getAttributeString("sourceScale", null), 100.0f); - boolean showTooltip = readBoolean(element, "showTooltip", false); + String tooltipHtml = compileLatexTooltip(element, templates, defaultNamespace, currentPageId, sceneResolver); + boolean showTooltip = tooltipHtml == null && readBoolean(element, "showTooltip", false); String valign = element.getAttributeString("valign", null); int offsetX = readInt(element, "offsetX", 0); int offsetY = readInt(element, "offsetY", 0); @@ -1192,6 +1247,7 @@ private String compileLatex(MdxJsxElementFields element, boolean display, GuideS scale, sourceScale, showTooltip, + tooltipHtml, valign, offsetX, offsetY, @@ -1200,8 +1256,8 @@ private String compileLatex(MdxJsxElementFields element, boolean display, GuideS } private String renderLatex(String formula, @Nullable String color, float scale, float sourceScale, - boolean showTooltip, @Nullable String valign, int offsetX, int offsetY, boolean display, - GuideSiteTemplateRegistry templates) { + boolean showTooltip, @Nullable String tooltipHtml, @Nullable String valign, int offsetX, int offsetY, + boolean display, GuideSiteTemplateRegistry templates) { String tag = display ? "div" : "span"; StringBuilder classes = new StringBuilder( display ? "guide-latex guide-latex-display" : "guide-latex guide-latex-inline"); @@ -1247,7 +1303,9 @@ private String renderLatex(String formula, @Nullable String color, float scale, } String templateId = null; - if (showTooltip) { + if (tooltipHtml != null) { + templateId = templates.create(tooltipHtml); + } else if (showTooltip) { templateId = templates.create("" + escapeHtml(formula) + ""); } @@ -1255,8 +1313,11 @@ private String renderLatex(String formula, @Nullable String color, float scale, html.append("<") .append(tag) .append(" class=\"") - .append(classes) - .append("\""); + .append(classes); + if (templateId != null) { + html.append(" guide-tooltip"); + } + html.append("\""); if (templateId != null) { html.append(" data-template=\"") .append(escapeAttribute(templateId)) @@ -1282,7 +1343,28 @@ private String renderLatexBody(String formula, @Nullable GuideSiteLatexExporter. return "\"""; + + "\" draggable=\"false\">"; + } + + @Nullable + private String compileLatexTooltip(MdxJsxElementFields element, GuideSiteTemplateRegistry templates, + String defaultNamespace, @Nullable ResourceLocation currentPageId, SceneResolver sceneResolver) { + if (!element.children() + .isEmpty()) { + String richTooltip = compileChildren( + element.children(), + templates, + defaultNamespace, + currentPageId, + sceneResolver); + if (hasText(richTooltip)) { + return richTooltip; + } + } + String tooltip = element.getAttributeString("tooltip", null); + return tooltip != null && !tooltip.trim() + .isEmpty() ? GuideSiteSceneAnnotationSerializer.renderTooltipHtml(new TextTooltip(tooltip), currentPageId) + : null; } private int displayWidth(GuideSiteLatexExporter.ExportedLatex exported, float scale) { @@ -1338,7 +1420,8 @@ private String compileLatexText(String text, GuideSiteTemplateRegistry templates StringBuilder html = new StringBuilder(); for (MarkdownLatexShorthand.Segment segment : MarkdownLatexShorthand.split(text)) { if (segment.isFormula()) { - html.append(renderLatex(segment.getValue(), null, 1.0f, 100.0f, false, null, 0, 0, false, templates)); + html.append( + renderLatex(segment.getValue(), null, 1.0f, 100.0f, false, null, null, 0, 0, false, templates)); } else { html.append(escapeHtml(segment.getValue())); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java index 6852e8b2..662fd956 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteLatexExporter.java @@ -61,7 +61,7 @@ public ExportedLatex export(String formula, int fillColorArgb, float sourceScale e.getMessage()); return null; } catch (Exception e) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "[GuideNH] [GuideSiteLatexExporter] Failed to export LaTeX formula '{}': {}", formula, e.getMessage(), diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java index de6bbecf..963c2763 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteMdxTagRenderer.java @@ -399,8 +399,13 @@ private String renderLayoutBox(MdxJsxElementFields element, String defaultNamesp style.append("width:100%;"); } - String body = compiler - .compileFragment(element.children(), templates, defaultNamespace, sceneResolver, currentPageId); + String body = compileLayoutBoxContent( + element, + defaultNamespace, + currentPageId, + templates, + sceneResolver, + compiler); return "
"; } + private String compileLayoutBoxContent(MdxJsxElementFields element, String defaultNamespace, + @Nullable ResourceLocation currentPageId, GuideSiteTemplateRegistry templates, + GuideSiteHtmlCompiler.SceneResolver sceneResolver, GuideSiteHtmlCompiler compiler) { + ParsedGuidePage parsedPage = currentPageId != null ? parsedPagesById.get(currentPageId) : null; + String rawBody = parsedPage != null ? MdxBlockTagSourceExtractor.extractRawBody(element, parsedPage.getSource()) + : null; + if (rawBody == null) { + return compiler + .compileFragment(element.children(), templates, defaultNamespace, sceneResolver, currentPageId); + } + return compileBlockMarkdownFragment( + DetailsContentExtractor.dedent(rawBody), + defaultNamespace, + currentPageId, + templates, + sceneResolver, + compiler); + } + private String renderStructure(MdxJsxElementFields element, @Nullable ResourceLocation currentPageId, GuideSiteTemplateRegistry templates) { int width = Math.max(32, readInt(element, "width", LytStructureView.DEFAULT_WIDTH)); diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java index f7417f0a..cdb82e8f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeExporter.java @@ -19,6 +19,13 @@ public class GuideSiteRecipeExporter { /** NEI slot chrome is commonly {@code 18脳18} pixels; vanilla/GT handlers draw {@code 16脳16} items inset by 1px. */ public static final int NEI_SLOT_GUI_PIXELS = 18; + @Nullable + private GuideSiteTemplateRegistry tooltipTemplates; + + public void setTooltipTemplates(@Nullable GuideSiteTemplateRegistry tooltipTemplates) { + this.tooltipTemplates = tooltipTemplates; + } + public String renderHtmlGrid(List> ingredients, String resultItemId) { return renderGrid( unresolvedItems(ingredients), @@ -97,9 +104,7 @@ private String renderGrid(List> ingredients, GuideSi html.append("
"); - // Emit the native `title=` tooltip so hovering the result slot reports the - // ItemStack display name even though we don't register a full template. - GuideSiteItemHtml.appendIcon(html, resultItem, null, 1f, true); + appendTooltipIcon(html, resultItem); html.append("
"); html.append(""); return html.toString(); @@ -269,7 +274,7 @@ private void appendSlotBoxes(StringBuilder html, List safeCandidates) { for (int i = 0; i < safeCandidates.size(); i++) { int beforeIcon = html.length(); - GuideSiteItemHtml.appendIcon(html, safeCandidates.get(i), null, 1f, true); + appendTooltipIcon(html, safeCandidates.get(i)); if (safeCandidates.size() > 1 && i == 0) { int classAttr = html.indexOf("class=\"", beforeIcon); if (classAttr >= 0) { @@ -280,6 +285,27 @@ private void appendSlotContents(StringBuilder html, List } } + private void appendTooltipIcon(StringBuilder html, GuideSiteExportedItem item) { + GuideSiteTemplateRegistry templates = tooltipTemplates; + if (templates == null || item.isEmpty()) { + GuideSiteItemHtml.appendIcon(html, item, null); + return; + } + + String label = item.displayName() + .isEmpty() ? item.itemId() : item.displayName(); + String templateId = templates.create( + "

" + GuideSiteItemHtml.escapeHtml(label) + + "

" + + GuideSiteItemHtml.escapeHtml(item.itemId()) + + "

"); + html.append(""); + GuideSiteItemHtml.appendIcon(html, item, null); + html.append(""); + } + public List> ingredientsFromVanillaEntry(RecipeLookup.Entry entry) { List> ingredients = new ArrayList<>(); for (int i = 0; i < entry.input3x3.length; i++) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeTagRenderer.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeTagRenderer.java index 1c00c237..5c033b19 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeTagRenderer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteRecipeTagRenderer.java @@ -372,6 +372,16 @@ public String render(MdxJsxElementFields element, String defaultNamespace) { usageQuery)); } + @Override + public String render(MdxJsxElementFields element, String defaultNamespace, GuideSiteTemplateRegistry templates) { + exporter.setTooltipTemplates(templates); + try { + return render(element, defaultNamespace); + } finally { + exporter.setTooltipTemplates(null); + } + } + @Override public String render(String recipeId, String fallbackText, String defaultNamespace) { return renderInternal( diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java index 7f8c7620..a75bc6c5 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneAnnotationSerializer.java @@ -49,11 +49,14 @@ import com.hfstudio.guidenh.guide.document.interaction.ItemTooltip; import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.guide.internal.tooltip.GuideItemTooltipLines; +import com.hfstudio.guidenh.guide.scene.GuidebookSceneLayerSelection; import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; import com.hfstudio.guidenh.guide.scene.annotation.DiamondAnnotation; +import com.hfstudio.guidenh.guide.scene.annotation.InWorldAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.InWorldBlockFaceOverlayAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.InWorldBoxAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.InWorldLineAnnotation; +import com.hfstudio.guidenh.guide.scene.annotation.OverlayAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.PonderInputAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.SceneAnnotation; import com.hfstudio.guidenh.guide.scene.annotation.TextAnnotation; @@ -100,15 +103,20 @@ public static AnnotationPayload serialize(LytGuidebookScene scene, GuideSiteTemp List> overlay = new ArrayList<>(); if (scene != null) { - for (SceneAnnotation annotation : scene.getAnnotations()) { - if (!scene.isStructureLibConditionSatisfied(annotation.getStructureLibCondition())) { - continue; - } + GuidebookSceneLayerSelection layerSelection = GuidebookSceneLayerSelection + .fromVisibleLayer(scene.getVisibleLayerYForExport()); + for (InWorldAnnotation annotation : scene.collectInWorldAnnotationsForExport(true, false, layerSelection)) { switch (annotation) { case InWorldBoxAnnotation box -> inWorld.add(serializeBox(box, templates, currentPageId, assetExporter, itemIconResolver)); case InWorldLineAnnotation line -> inWorld.add(serializeLine(line, templates, currentPageId, assetExporter, itemIconResolver)); case InWorldBlockFaceOverlayAnnotation blockOverlay -> inWorld.add( serializeBlockOverlay(blockOverlay, templates, currentPageId, assetExporter, itemIconResolver)); + default -> { + } + } + } + for (OverlayAnnotation annotation : scene.collectOverlayAnnotationsForExport(layerSelection)) { + switch (annotation) { case DiamondAnnotation diamond -> overlay.add(serializeDiamond(diamond, templates, currentPageId, assetExporter, itemIconResolver)); case TextAnnotation textAnnotation -> overlay.add( serializeTextAnnotation( diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneRuntimeExporter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneRuntimeExporter.java index b01402e7..41bed9ea 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneRuntimeExporter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneRuntimeExporter.java @@ -7,6 +7,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.zip.GZIPOutputStream; import javax.imageio.ImageIO; @@ -29,6 +32,7 @@ import com.hfstudio.guidenh.guide.scene.GuidebookSceneLayerSelection; import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.guide.siteexport.site.GuideSiteSceneTessellatorCapture.TextureExportCache; import guideme.flatbuffers.scene.ExpAnimatedTexturePart; import guideme.flatbuffers.scene.ExpAnimatedTexturePartFrame; @@ -42,7 +46,7 @@ import guideme.flatbuffers.scene.ExpVertexFormat; import guideme.flatbuffers.scene.ExpVertexFormatElement; -public class GuideSiteSceneRuntimeExporter { +public class GuideSiteSceneRuntimeExporter implements AutoCloseable { private static final int PLACEHOLDER_SCALE = 2; private static final ResourceLocation RAIN_TEXTURE = new ResourceLocation( @@ -55,14 +59,34 @@ public class GuideSiteSceneRuntimeExporter { private static final String SNOW_ANIMATED_TEXTURE_ID = "guidenh-weather-snow"; private final GuideSiteAssetRegistry assets; + private final TextureExportCache textureCache = new TextureExportCache(); + private final ExecutorService encodingExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "guidenh-site-scene-encode"); + thread.setDaemon(true); + return thread; + }); public GuideSiteSceneRuntimeExporter(GuideSiteAssetRegistry assets) { this.assets = assets; } - public GuideSiteExportedScene exportScene(LytGuidebookScene scene) throws Exception { - byte[] placeholderBytes = renderPlaceholder(scene); - byte[] sceneBytes = exportScenePayload(scene); + public GuideSiteExportedScene exportScene(LytGuidebookScene scene, boolean includePlaceholder) throws Exception { + if (!includePlaceholder) { + String scenePath = assets.writeShared("scenes", ".scene.gz", exportScenePayload(scene)); + return new GuideSiteExportedScene(null, scenePath, scene.getSceneWidth(), scene.getSceneHeight()); + } + + BufferedImage placeholderImage = renderPlaceholderImage(scene); + Future placeholderEncoding = encodingExecutor.submit(() -> encodePng(placeholderImage)); + byte[] sceneBytes; + byte[] placeholderBytes; + try { + sceneBytes = exportScenePayload(scene); + placeholderBytes = placeholderEncoding.get(); + } catch (Exception e) { + placeholderEncoding.cancel(true); + throw e; + } GuideSiteSceneExporter exporter = new GuideSiteSceneExporter(assets, () -> placeholderBytes, () -> sceneBytes); GuideSiteSceneExporter.SceneFiles files = exporter.writeSceneAssets(); @@ -73,7 +97,12 @@ public GuideSiteExportedScene exportScene(LytGuidebookScene scene) throws Except scene.getSceneHeight()); } - private byte[] renderPlaceholder(LytGuidebookScene scene) throws Exception { + @Override + public void close() { + encodingExecutor.shutdownNow(); + } + + private BufferedImage renderPlaceholderImage(LytGuidebookScene scene) throws Exception { int originalBackground = scene.getSceneBackgroundColor(); int originalBorder = scene.getSceneBorderColor(); int originalWidth = scene.getSceneWidth(); @@ -103,9 +132,7 @@ private byte[] renderPlaceholder(LytGuidebookScene scene) throws Exception { image = framebuffer.render(scene); } - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ImageIO.write(image, "png", out); - return out.toByteArray(); + return image; } finally { scene.setSceneBackgroundColor(originalBackground); scene.setSceneBorderColor(originalBorder); @@ -117,11 +144,20 @@ private byte[] renderPlaceholder(LytGuidebookScene scene) throws Exception { } } + private byte[] encodePng(BufferedImage image) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ImageIO.write(image, "png", out); + return out.toByteArray(); + } + private byte[] exportScenePayload(LytGuidebookScene scene) throws Exception { Matrix4f inverseViewMatrix = new Matrix4f( scene.getCamera() .getViewMatrix()).invert(); - GuideSiteSceneTessellatorCapture recorder = new GuideSiteSceneTessellatorCapture(assets, inverseViewMatrix); + GuideSiteSceneTessellatorCapture recorder = new GuideSiteSceneTessellatorCapture( + assets, + textureCache, + inverseViewMatrix); int width = Math.max(16, scene.getSceneWidth()); int height = Math.max(16, scene.getSceneHeight()); @@ -134,7 +170,7 @@ private byte[] exportScenePayload(LytGuidebookScene scene) throws Exception { GuideSiteSceneTessellatorCapture.RecordingResult result = recorder.finish(); if (result.meshes.isEmpty()) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "Scene site export captured no tessellated meshes for a {}x{} scene; exported 3D preview will be blank.", width, height); diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneTessellatorCapture.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneTessellatorCapture.java index c29639c8..b73f65a0 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneTessellatorCapture.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteSceneTessellatorCapture.java @@ -1,10 +1,12 @@ package com.hfstudio.guidenh.guide.siteexport.site; import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; import java.io.ByteArrayOutputStream; import java.nio.ByteBuffer; import java.nio.FloatBuffer; import java.util.ArrayList; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -36,6 +38,7 @@ public class GuideSiteSceneTessellatorCapture { private static volatile @Nullable GuideSiteSceneTessellatorCapture ACTIVE; private final GuideSiteAssetRegistry assets; + private final TextureExportCache textureCache; private final Matrix4f inverseViewMatrix; private final Matrix4f currentWorldMatrix = new Matrix4f(); private final Matrix4f modelViewMatrix = new Matrix4f(); @@ -56,9 +59,12 @@ public class GuideSiteSceneTessellatorCapture { private int currentVertexCount; @Nullable private String currentSourceTextureId; + private boolean capturingParticles; - public GuideSiteSceneTessellatorCapture(GuideSiteAssetRegistry assets, Matrix4f inverseViewMatrix) { + public GuideSiteSceneTessellatorCapture(GuideSiteAssetRegistry assets, TextureExportCache textureCache, + Matrix4f inverseViewMatrix) { this.assets = assets; + this.textureCache = textureCache; this.inverseViewMatrix = new Matrix4f(inverseViewMatrix); } @@ -100,10 +106,14 @@ public void setCurrentSourceTextureId(@Nullable String currentSourceTextureId) { this.currentSourceTextureId = currentSourceTextureId; } + public void setCapturingParticles(boolean capturingParticles) { + this.capturingParticles = capturingParticles; + } + public void startDrawing(int drawMode) { if (drawing) { // A previous batch was not properly closed, so drop it before recording a new one. - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "Scene capture startDrawing called while already drawing (mode={}); discarding previous unclosed batch", drawMode); drawing = false; @@ -132,7 +142,7 @@ public int draw() { captureCurrentMesh(); } } catch (Throwable e) { - GuideDebugLog.warnAlways("Scene capture mesh export failed ({} vertices)", vertexCount, e); + GuideDebugLog.warn("Scene capture mesh export failed ({} vertices)", vertexCount, e); } finally { currentVertexBytes = EMPTY_VERTEX_BYTES; currentVertexCount = 0; @@ -229,7 +239,7 @@ private TextureExport exportCurrentTexture() throws Exception { int level0Width = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_WIDTH); int level0Height = GL11.glGetTexLevelParameteri(GL11.GL_TEXTURE_2D, 0, GL11.GL_TEXTURE_HEIGHT); if (level0Width <= 0 || level0Height <= 0) { - GuideDebugLog.warnAlways( + GuideDebugLog.warn( "exportCurrentTexture: bound texture id={} has invalid level-0 dimensions {}x{}; skipping", textureId, level0Width, @@ -254,6 +264,30 @@ private TextureExport exportCurrentTexture() throws Exception { exportHeight = lh; if (lw <= MAX_EXPORT_TEXTURE_SIZE && lh <= MAX_EXPORT_TEXTURE_SIZE) break; } + } + + int magFilter = GL11.glGetTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER); + int minFilter = GL11.glGetTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER); + boolean linearFiltering = magFilter == GL11.GL_LINEAR; + boolean useMipmaps = minFilter == GL11.GL_NEAREST_MIPMAP_NEAREST + || minFilter == GL11.GL_LINEAR_MIPMAP_NEAREST + || minFilter == GL11.GL_NEAREST_MIPMAP_LINEAR + || minFilter == GL11.GL_LINEAR_MIPMAP_LINEAR; + TextureCacheKey cacheKey = new TextureCacheKey( + textureId, + level0Width, + level0Height, + exportMipLevel, + currentSourceTextureId, + linearFiltering, + useMipmaps); + TextureExport cached = textureCache.get(cacheKey); + if (cached != null) { + textures.put(textureId, cached); + return cached; + } + + if (exportMipLevel > 0) { GuideDebugLog.debugAlways( "exportCurrentTexture: texture id={} is {}x{} - using mip level {} ({}x{}) for site export", textureId, @@ -274,15 +308,14 @@ private TextureExport exportCurrentTexture() throws Exception { GL11.glGetTexImage(GL11.GL_TEXTURE_2D, exportMipLevel, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, pixels); BufferedImage image = new BufferedImage(exportWidth, exportHeight, BufferedImage.TYPE_INT_ARGB); - for (int y = 0; y < exportHeight; y++) { - for (int x = 0; x < exportWidth; x++) { - int index = (x + y * exportWidth) * 4; - int r = pixels.get(index) & 0xFF; - int g = pixels.get(index + 1) & 0xFF; - int b = pixels.get(index + 2) & 0xFF; - int a = pixels.get(index + 3) & 0xFF; - image.setRGB(x, y, a << 24 | r << 16 | g << 8 | b); - } + int[] argbPixels = ((DataBufferInt) image.getRaster() + .getDataBuffer()).getData(); + for (int pixelIndex = 0, byteIndex = 0; pixelIndex < argbPixels.length; pixelIndex++, byteIndex += 4) { + int r = pixels.get(byteIndex) & 0xFF; + int g = pixels.get(byteIndex + 1) & 0xFF; + int b = pixels.get(byteIndex + 2) & 0xFF; + int a = pixels.get(byteIndex + 3) & 0xFF; + argbPixels[pixelIndex] = a << 24 | r << 16 | g << 8 | b; } ByteArrayOutputStream out = new ByteArrayOutputStream(); @@ -290,14 +323,6 @@ private TextureExport exportCurrentTexture() throws Exception { String texturePath = assets.writeShared("scene-textures", ".png", out.toByteArray()); - int magFilter = GL11.glGetTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER); - int minFilter = GL11.glGetTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER); - boolean linearFiltering = magFilter == GL11.GL_LINEAR; - boolean useMipmaps = minFilter == GL11.GL_NEAREST_MIPMAP_NEAREST - || minFilter == GL11.GL_LINEAR_MIPMAP_NEAREST - || minFilter == GL11.GL_NEAREST_MIPMAP_LINEAR - || minFilter == GL11.GL_LINEAR_MIPMAP_LINEAR; - TextureExport export = new TextureExport( "gltex-" + textureId, texturePath, @@ -305,6 +330,7 @@ private TextureExport exportCurrentTexture() throws Exception { linearFiltering, useMipmaps); textures.put(textureId, export); + textureCache.put(cacheKey, export); return export; } finally { if (savedActiveUnit != OpenGlHelper.defaultTexUnit) { @@ -315,7 +341,6 @@ private TextureExport exportCurrentTexture() throws Exception { private MaterialKey createMaterialKey(@Nullable TextureExport texture) { boolean blendEnabled = GL11.glIsEnabled(GL11.GL_BLEND); - boolean cullEnabled = GL11.glIsEnabled(GL11.GL_CULL_FACE); boolean depthEnabled = GL11.glIsEnabled(GL11.GL_DEPTH_TEST); int transparency = mapTransparency(blendEnabled); @@ -333,14 +358,14 @@ private MaterialKey createMaterialKey(@Nullable TextureExport texture) { } return new MaterialKey( - "scene-mesh", + capturingParticles ? "scene-particle" : "scene-mesh", shaderName, texture != null ? texture.textureId : null, texture != null ? texture.texturePath : null, texture != null ? texture.sourceTextureId : null, texture != null && texture.linearFiltering, texture != null && texture.useMipmaps, - !cullEnabled, + true, transparency, depthTest); } @@ -770,4 +795,23 @@ private static class TextureExport { } } + public static class TextureExportCache { + + private final Map exports = new HashMap<>(); + + public TextureExportCache() {} + + @Nullable + private TextureExport get(TextureCacheKey key) { + return exports.get(key); + } + + private void put(TextureCacheKey key, TextureExport export) { + exports.put(key, export); + } + } + + private record TextureCacheKey(int textureId, int level0Width, int level0Height, int exportMipLevel, + @Nullable String sourceTextureId, boolean linearFiltering, boolean useMipmaps) {} + } diff --git a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteWriter.java b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteWriter.java index f6a3df8b..49e60bad 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteWriter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/siteexport/site/GuideSiteWriter.java @@ -292,7 +292,7 @@ public String renderSidebar(MutableGuide guide, String language, NavigationTree .append(escapeHtml(uiText.searchEmptyTemplate())) .append("\" hidden>
"); html.append(""); - html.append("