diff --git a/.gitignore b/.gitignore index 3a66b6a8..0e96f79e 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,5 @@ layout.json /src/test /docs /gradle-user +.claude/ +CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f5bd0a58 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,17 @@ +需要进行spotlessApply和compileJava验证 + +禁止全限定名引用类,只能import使用 + +禁止包私有和final类,record记录类除外,所有注释使用英语 + +禁止// -------------和// =======这样的注释 +先查找D:\\Github\\GTNH有没有需要的源码库 + +禁止过于耦合,尽可能保证解耦并且支持高度可拓展 + +确保代码实现的干净漂亮,性能高效,业务清晰 + +所有文件均按照UTF8读取和编写,禁止UTF16等,powershell读取也强制指定UTF8编码 + +开发文档禁止提交到git,测试文件是故意被git忽略的 + diff --git a/README.md b/README.md index 6cb0cabf..f6b7f8a1 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ## **Features** * Markdown pages with YAML frontmatter, navigation metadata, categories, anchors, tables, footnotes, Mermaid, LaTeX, charts, and highlighted text. -* MDX-style runtime tags such as ``, ``, ``, ``, ``, ``, ``, and ``. +* MDX-style runtime tags such as ``, ``, ``, ``, ``, ``, ``, ``, and ``. * Interactive 3D GameScene previews with block/entity placement, StructureLib import, Ponder playback, layer sliders, grid controls, annotations, and block statistics. * Live guide editing mode with split editor/preview, toolbar actions, debounced saving, external-change handling, and resource-pack page creation. * Multi-language guide folders with fallback, item index navigation, search, server integration, and resource reload support. diff --git a/README_zh.md b/README_zh.md index 0109a8c4..c43236cb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -22,7 +22,7 @@ ## **功能** * 支持带 YAML 表头的 Markdown 页面、导航元数据、分类、锚点、表格、脚注、Mermaid、LaTeX、图表与文本高亮。 -* 支持 ``、``、``、``、``、``、``、`` 等 MDX 风格运行时标签。 +* 支持 ``、``、``、``、``、``、``、``、`` 等 MDX 风格运行时标签。 * 支持交互式 3D GameScene 预览,包括方块/实体放置、StructureLib 导入、Ponder 播放、层滑条、网格按钮、注解与方块统计表。 * 支持游戏内指南编辑模式,包括编辑/预览分屏、工具栏操作、短延迟自动保存、外部变更处理与资源包页面创建。 * 支持多语言指南目录与回退、物品索引跳转、搜索、服务端集成和资源重载。 diff --git a/dependencies.gradle b/dependencies.gradle index af1231e9..5815c5eb 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -1,6 +1,6 @@ dependencies { - api("com.github.GTNewHorizons:GTNHLib:0.10.3:dev") + api("com.github.GTNewHorizons:GTNHLib:0.10.8:dev") implementation("io.github.legacymoddingmc:unimixins:0.3.1:dev") shadowImplementation("org.yaml:snakeyaml:1.33") { transitive = false } @@ -23,15 +23,17 @@ dependencies { devOnlyNonPublishable("com.github.GTNewHorizons:nei-custom-diagram:1.8.20:dev") devOnlyNonPublishable("com.github.GTNewHorizons:EnhancedLootBags:1.3.4:dev") devOnlyNonPublishable("com.github.GTNewHorizons:BetterQuesting:3.8.40-GTNH:dev") + devOnlyNonPublishable("com.github.GTNewHorizons:Angelica:2.1.32:dev") + devOnlyNonPublishable("com.github.GTNewHorizons:TX-Loader:1.8.11:dev") devOnlyNonPublishable("curse.maven:cofh-core-69162:2388751") runtimeOnlyNonPublishable("com.github.GTNewHorizons:Baubles-Expanded:2.2.13-GTNH:dev") runtimeOnlyNonPublishable("com.github.GTNewHorizons:Botania:1.13.16-GTNH:dev") runtimeOnlyNonPublishable("com.github.GTNewHorizons:Botanic-horizons:1.12.8-GTNH:dev") runtimeOnlyNonPublishable("com.github.GTNewHorizons:BlockRenderer6343:1.4.12:dev") { transitive = false } - runtimeOnlyNonPublishable("com.github.GTNewHorizons:Angelica:2.1.19:dev") runtimeOnlyNonPublishable(rfg.deobf("curse.maven:spark-361579:4271867")) + compileOnlyApi("com.github.slprime:ChromaticTooltips:1.0.28-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:TinkersConstruct:1.14.64-GTNH:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:Mantle:0.5.1:dev") { transitive = false } compileOnlyApi("com.github.GTNewHorizons:Mobs-Info:0.5.14-GTNH:dev") { transitive = false } diff --git a/src/main/java/com/hfstudio/guidenh/ClientProxy.java b/src/main/java/com/hfstudio/guidenh/ClientProxy.java index 2dd945a4..ac234b63 100644 --- a/src/main/java/com/hfstudio/guidenh/ClientProxy.java +++ b/src/main/java/com/hfstudio/guidenh/ClientProxy.java @@ -11,21 +11,69 @@ import com.hfstudio.guidenh.client.command.GuideNhClientBridgeController; import com.hfstudio.guidenh.client.command.GuideNhClientCommand; import com.hfstudio.guidenh.client.hotkey.CycleRegionWandModeHotkey; +import com.hfstudio.guidenh.client.hotkey.GuidePageHistoryHotkey; import com.hfstudio.guidenh.client.hotkey.OpenGuideHomeHotkey; import com.hfstudio.guidenh.client.hotkey.OpenGuideHotkey; import com.hfstudio.guidenh.client.hotkey.OpenSceneEditorHotkey; import com.hfstudio.guidenh.config.ModConfig; -import com.hfstudio.guidenh.guide.internal.GuideDevWatcherPump; import com.hfstudio.guidenh.guide.internal.GuideDevelopmentResourcePackWatcher; import com.hfstudio.guidenh.guide.internal.GuideME; import com.hfstudio.guidenh.guide.internal.GuideOnStartup; -import com.hfstudio.guidenh.guide.internal.GuideRegistry; import com.hfstudio.guidenh.guide.internal.GuideReloadListener; -import com.hfstudio.guidenh.guide.internal.GuideScreenMemory; -import com.hfstudio.guidenh.guide.internal.GuideWarmupPump; -import com.hfstudio.guidenh.guide.internal.home.GuideScreenHomeHistory; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TagAttributeRegistry; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.AnchorProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.AttributeNameProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.AttributePresetValueProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.AutocompleteProviders; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.BlockIdProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.BooleanValueProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.ColorProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.CommandProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.DomainProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.EntityNameProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.EnumValueProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.ExpressionProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.FencedBlockLanguageProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.FormatPatternProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.FrontmatterKeyProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.FrontmatterValueProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.ImagePathProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.ItemIdProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.KeyBindProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.NumericValueProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.OreDictProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.PageReferenceProvider; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.TagNameProvider; +import com.hfstudio.guidenh.guide.internal.host.LytHost; +import com.hfstudio.guidenh.guide.internal.host.LytHostWorkItem; +import com.hfstudio.guidenh.guide.internal.host.scripts.BlockImageScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.CategoryScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.CommandLinkScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.CsvTableScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.FloatingImageScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.ImageScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.ItemGridScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.ItemImageScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.ItemLinkScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.KeyBindScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.MermaidScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.PlayerNameScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.QuestCardScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.QuestLinkScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.RecipeScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.SceneScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.SoundLinkScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.SpecialScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.StructureScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.SubPagesScript; +import com.hfstudio.guidenh.guide.internal.host.scripts.TooltipScript; +import com.hfstudio.guidenh.guide.internal.scheduler.DevWatchWorkItem; +import com.hfstudio.guidenh.guide.internal.scheduler.LytHostPreheatItem; +import com.hfstudio.guidenh.guide.internal.scheduler.MasterScheduler; +import com.hfstudio.guidenh.guide.internal.scheduler.SearchIndexWorkItem; import com.hfstudio.guidenh.guide.scene.level.GuidebookFakeWorld; import com.hfstudio.guidenh.guide.scene.level.GuidebookLevel; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.integration.GuideNhClientIntegrationBootstrap; import com.hfstudio.guidenh.integration.Mods; import com.hfstudio.guidenh.integration.ae2.network.Ae2NetworkRegistration; @@ -47,6 +95,12 @@ public class ClientProxy extends CommonProxy { + private static final LytHost lytHost = new LytHost(); + + public static LytHost getLytHost() { + return lytHost; + } + private final GuideNhRuntimeBridge runtimeBridge = new GuideNhRuntimeBridge(); @Override @@ -79,12 +133,76 @@ public void init(FMLInitializationEvent event) { } OpenGuideHomeHotkey.init(); OpenGuideHotkey.init(); + GuidePageHistoryHotkey.init(); OpenSceneEditorHotkey.init(); + AutocompleteProviders.register(new ItemIdProvider()); + TagAttributeRegistry.initialize(); + AutocompleteProviders.register(new AttributeNameProvider()); + + AutocompleteProviders.register(new ColorProvider()); + AutocompleteProviders.register(new OreDictProvider()); + AutocompleteProviders.register(new BlockIdProvider()); + AutocompleteProviders.register(new EntityNameProvider()); + AutocompleteProviders.register(new KeyBindProvider()); + AutocompleteProviders.register(new PageReferenceProvider()); + AutocompleteProviders.register(new AnchorProvider()); + AutocompleteProviders.register(new CommandProvider()); + AutocompleteProviders.register(new AttributePresetValueProvider()); + AutocompleteProviders.register(new NumericValueProvider()); + AutocompleteProviders.register(new BooleanValueProvider()); + AutocompleteProviders.register(new EnumValueProvider()); + AutocompleteProviders.register(new ExpressionProvider()); + AutocompleteProviders.register(new DomainProvider()); + AutocompleteProviders.register(new FormatPatternProvider()); + AutocompleteProviders.register(new TagNameProvider()); + AutocompleteProviders.register(new FencedBlockLanguageProvider()); + AutocompleteProviders.register(new FrontmatterKeyProvider()); + AutocompleteProviders.register(new FrontmatterValueProvider()); + + AutocompleteProviders.register(new ImagePathProvider()); CycleRegionWandModeHotkey.init(); MinecraftForge.EVENT_BUS.register(new RegionWandRenderer()); - GuideWarmupPump.init(); + MasterScheduler.init(); + MasterScheduler.getInstance() + .submit(new LytHostWorkItem(lytHost)); + MasterScheduler.getInstance() + .submit(new LytHostPreheatItem(lytHost)); + MasterScheduler.getInstance() + .submit(new SearchIndexWorkItem()); + + // Phase 3: LytScript registrations + lytHost.registerScript("CommandLink", new CommandLinkScript()); + lytHost.registerScript("Img", new ImageScript()); + lytHost.registerScript("FloatingImage", new FloatingImageScript()); + lytHost.registerScript("PlayerName", new PlayerNameScript()); + lytHost.registerScript("KeyBind", new KeyBindScript()); + lytHost.registerScript("SoundLink", new SoundLinkScript()); + lytHost.registerScript("Structure", new StructureScript()); + lytHost.registerScript("SubPages", new SubPagesScript()); + lytHost.registerScript("Tooltip", new TooltipScript()); + lytHost.registerScript("ItemGrid", new ItemGridScript()); + lytHost.registerScript("ItemImage", new ItemImageScript()); + lytHost.registerScript("ItemLink", new ItemLinkScript()); + lytHost.registerScript("Category", new CategoryScript()); + lytHost.registerScript("Special", new SpecialScript()); + lytHost.registerScript("BlockImage", new BlockImageScript()); + lytHost.registerScript("CsvTable", new CsvTableScript()); + lytHost.registerScript("Mermaid", new MermaidScript()); + lytHost.registerScript("QuestLink", new QuestLinkScript()); + lytHost.registerScript("QuestCard", new QuestCardScript()); + // Phase 3: SceneScript handles Scene and GameScene + SceneScript sceneScript = new SceneScript(); + lytHost.registerScript("Scene", sceneScript); + lytHost.registerScript("GameScene", sceneScript); + // Phase 3: RecipeScript handles Recipe, RecipeFor, RecipeUsage, RecipesFor + RecipeScript recipeScript = new RecipeScript(); + lytHost.registerScript("Recipe", recipeScript); + lytHost.registerScript("RecipeFor", recipeScript); + lytHost.registerScript("RecipeUsage", recipeScript); + lytHost.registerScript("RecipesFor", recipeScript); + MinecraftForge.EVENT_BUS.register(this); - GuideNH.LOG.info( + GuideDebugLog.infoAlways( "GuideNH runtime bridge configuration loaded. enabled={}, hostConfigured={}, port={}, tokenConfigured={}", ModConfig.runtimeBridge.enabled, ModConfig.runtimeBridge.host != null && !ModConfig.runtimeBridge.host.trim() @@ -113,20 +231,17 @@ public void postInit(FMLPostInitializationEvent event) { public void completeInit(FMLLoadCompleteEvent event) { super.completeInit(event); GuideDevelopmentResourcePackWatcher.init(); - GuideDevWatcherPump.init(); + MasterScheduler.getInstance() + .submit(new DevWatchWorkItem()); GuideOnStartup.init(); } @SubscribeEvent public void onClientDisconnect(FMLNetworkEvent.ClientDisconnectionFromServerEvent event) { - GuideNH.LOG.info("Minecraft client disconnected. Stopping GuideNH runtime bridge session state"); + GuideDebugLog.infoAlways("Minecraft client disconnected. Stopping GuideNH runtime bridge session state"); runtimeBridge.stop(); GuideME.closeSearch(); - GuideScreenMemory.clear(); - GuideScreenHomeHistory.shared() + lytHost.getNavigation() .clear(); - for (var guide : GuideRegistry.getAll()) { - guide.resetWarmup(); - } } } diff --git a/src/main/java/com/hfstudio/guidenh/GuideNH.java b/src/main/java/com/hfstudio/guidenh/GuideNH.java index d6366e3c..a9b448eb 100644 --- a/src/main/java/com/hfstudio/guidenh/GuideNH.java +++ b/src/main/java/com/hfstudio/guidenh/GuideNH.java @@ -3,9 +3,6 @@ import static com.hfstudio.guidenh.GuideNH.MODID; import static com.hfstudio.guidenh.GuideNH.MODNAME; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - import com.hfstudio.guidenh.guide.internal.GuideCommand; import com.hfstudio.guidenh.guide.internal.GuideNhBridgeCommand; import com.hfstudio.guidenh.guide.internal.item.GuideItem; @@ -35,7 +32,6 @@ public class GuideNH { public static final String MODNAME = Tags.MODNAME; public static final String VERSION = Tags.VERSION; public static final String AUTHOR = "HFstudio"; - public static final Logger LOG = LogManager.getLogger(MODID); public static boolean debug = false; diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java index 274e5b8b..67c50a26 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridge.java @@ -1,6 +1,6 @@ package com.hfstudio.guidenh.bridge; -import com.hfstudio.guidenh.GuideNH; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class GuideNhRuntimeBridge { @@ -9,7 +9,7 @@ public class GuideNhRuntimeBridge { public void start(GuideNhRuntimeBridgeSettings settings) { stop(); if (!settings.canStart()) { - GuideNH.LOG.info( + GuideDebugLog.infoAlways( "GuideNH runtime bridge start skipped. enabled={}, hostConfigured={}, portConfigured={}, tokenConfigured={}", settings.isEnabled(), !settings.getHost() @@ -19,7 +19,7 @@ public void start(GuideNhRuntimeBridgeSettings settings) { .isEmpty()); return; } - GuideNH.LOG.info( + GuideDebugLog.infoAlways( "Starting GuideNH runtime bridge. host={}, port={}, maxConnections={}, maxMessageBytes={}, maxPageSize={}, maxSubscriptions={}, maxDeltaEntries={}", settings.getHost(), settings.getPort(), @@ -34,7 +34,7 @@ public void start(GuideNhRuntimeBridgeSettings settings) { public void stop() { if (server != null) { - GuideNH.LOG.info("Stopping GuideNH runtime bridge"); + GuideDebugLog.infoAlways("Stopping GuideNH runtime bridge"); server.stop(); server = null; } diff --git a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java index a123a2db..77ddf64f 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/GuideNhRuntimeBridgeServer.java @@ -16,7 +16,6 @@ import org.jetbrains.annotations.NotNull; -import com.hfstudio.guidenh.GuideNH; import com.hfstudio.guidenh.bridge.preview.ItemPreviewCache; import com.hfstudio.guidenh.bridge.preview.ItemPreviewSearchService; import com.hfstudio.guidenh.bridge.preview.ItemPreviewService; @@ -27,6 +26,7 @@ import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; import com.hfstudio.guidenh.bridge.semantic.providers.RuntimeSemanticProviders; import com.hfstudio.guidenh.bridge.transport.RuntimeBridgeConnection; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class GuideNhRuntimeBridgeServer { @@ -63,15 +63,17 @@ public void start() { return; } try { - GuideNH.LOG.info("Binding GuideNH runtime bridge server to {}:{}", settings.getHost(), settings.getPort()); + GuideDebugLog + .infoAlways("Binding GuideNH runtime bridge server to {}:{}", settings.getHost(), settings.getPort()); serverSocket = new ServerSocket(); serverSocket.bind(new InetSocketAddress(settings.getHost(), settings.getPort())); executor.execute(this::acceptConnections); - GuideNH.LOG.info("GuideNH runtime bridge started at ws://{}:{}", settings.getHost(), settings.getPort()); + GuideDebugLog + .infoAlways("GuideNH runtime bridge started at ws://{}:{}", settings.getHost(), settings.getPort()); } catch (IOException e) { running.set(false); closeServerSocket(); - GuideNH.LOG.warn("Failed to start GuideNH runtime bridge", e); + GuideDebugLog.warnAlways("Failed to start GuideNH runtime bridge", e); } } @@ -79,7 +81,7 @@ public void stop() { if (!running.getAndSet(false)) { return; } - GuideNH.LOG.info("GuideNH runtime bridge server stopping"); + GuideDebugLog.infoAlways("GuideNH runtime bridge server stopping"); closeServerSocket(); List snapshot; synchronized (connections) { @@ -101,9 +103,9 @@ private void acceptConnections() { try { Socket socket = serverSocket.accept(); String remoteAddress = describeRemote(socket); - GuideNH.LOG.info("GuideNH runtime bridge accepted socket from {}", remoteAddress); + GuideDebugLog.infoAlways("GuideNH runtime bridge accepted socket from {}", remoteAddress); if (connections.size() >= limits.getMaxConnections()) { - GuideNH.LOG.warn( + GuideDebugLog.warnAlways( "GuideNH runtime bridge rejected socket from {} because maxConnections={} has been reached", remoteAddress, limits.getMaxConnections()); @@ -119,14 +121,14 @@ private void acceptConnections() { limits, this::handleClosedConnection); connections.add(connection); - GuideNH.LOG.info( + GuideDebugLog.infoAlways( "GuideNH runtime bridge starting session for {}. activeConnections={}", remoteAddress, connections.size()); executor.execute(connection); } catch (IOException e) { if (running.get()) { - GuideNH.LOG.warn("GuideNH runtime bridge accept loop failed", e); + GuideDebugLog.warnAlways("GuideNH runtime bridge accept loop failed", e); } } } @@ -149,7 +151,7 @@ private String describeRemote(Socket socket) { private void handleClosedConnection(RuntimeBridgeConnection connection) { connections.remove(connection); - GuideNH.LOG.info( + GuideDebugLog.infoAlways( "GuideNH runtime bridge session closed for {}. activeConnections={}", connection.getRemoteAddress(), connections.size()); diff --git a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java index 677289a9..f98f6dc4 100644 --- a/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java +++ b/src/main/java/com/hfstudio/guidenh/bridge/transport/RuntimeBridgeConnection.java @@ -7,7 +7,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import com.hfstudio.guidenh.GuideNH; import com.hfstudio.guidenh.bridge.preview.PreviewQueryFactory; import com.hfstudio.guidenh.bridge.preview.PreviewResolveQuery; import com.hfstudio.guidenh.bridge.preview.PreviewResolveResult; @@ -22,6 +21,7 @@ import com.hfstudio.guidenh.bridge.semantic.SemanticProviderRegistry; import com.hfstudio.guidenh.bridge.semantic.SemanticQuery; import com.hfstudio.guidenh.bridge.semantic.SemanticQueryFactory; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class RuntimeBridgeConnection implements Runnable { @@ -60,19 +60,21 @@ public RuntimeBridgeConnection(Socket socket, BridgeMessageCodec messageCodec, public void run() { try { socket.setSoTimeout(SOCKET_TIMEOUT_MILLIS); - GuideNH.LOG.info("GuideNH runtime bridge waiting for WebSocket handshake from {}", describeRemote()); + GuideDebugLog + .infoAlways("GuideNH runtime bridge waiting for WebSocket handshake from {}", describeRemote()); if (!new WebSocketHandshake().accept(socket.getInputStream(), socket.getOutputStream())) { - GuideNH.LOG - .warn("GuideNH runtime bridge rejected invalid WebSocket handshake from {}", describeRemote()); + GuideDebugLog.warnAlways( + "GuideNH runtime bridge rejected invalid WebSocket handshake from {}", + describeRemote()); return; } - GuideNH.LOG.info("GuideNH runtime bridge WebSocket handshake completed for {}", describeRemote()); + GuideDebugLog.infoAlways("GuideNH runtime bridge WebSocket handshake completed for {}", describeRemote()); readFrames(); } catch (SocketTimeoutException ignored) { - GuideNH.LOG.warn("GuideNH runtime bridge connection timed out for {}", describeRemote()); + GuideDebugLog.warnAlways("GuideNH runtime bridge connection timed out for {}", describeRemote()); closeQuietly(); } catch (IOException e) { - GuideNH.LOG.warn("GuideNH runtime bridge connection I/O failed for {}", describeRemote(), e); + GuideDebugLog.warnAlways("GuideNH runtime bridge connection I/O failed for {}", describeRemote(), e); closeQuietly(); } finally { close(); @@ -83,7 +85,7 @@ public void close() { if (!closed.compareAndSet(false, true)) { return; } - GuideNH.LOG.info("GuideNH runtime bridge closing connection for {}", describeRemote()); + GuideDebugLog.infoAlways("GuideNH runtime bridge closing connection for {}", describeRemote()); closeQuietly(); closeCallback.accept(this); } @@ -156,12 +158,12 @@ private BridgeEnvelope handleHello(BridgeEnvelope envelope) { .get("token") .getAsString() : ""; if (!authenticator.matches(token)) { - GuideNH.LOG.warn("GuideNH runtime bridge authentication failed for {}", describeRemote()); + GuideDebugLog.warnAlways("GuideNH runtime bridge authentication failed for {}", describeRemote()); return responseFactory .error(envelope.getId(), envelope.getMethod(), "unauthorized", "Invalid bridge token", false); } authenticated = true; - GuideNH.LOG.info("GuideNH runtime bridge authenticated {}", describeRemote()); + GuideDebugLog.infoAlways("GuideNH runtime bridge authenticated {}", describeRemote()); return responseFactory.hello(envelope.getId(), limits); } diff --git a/src/main/java/com/hfstudio/guidenh/client/hotkey/GuidePageHistoryHotkey.java b/src/main/java/com/hfstudio/guidenh/client/hotkey/GuidePageHistoryHotkey.java new file mode 100644 index 00000000..19f6b11c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/client/hotkey/GuidePageHistoryHotkey.java @@ -0,0 +1,58 @@ +package com.hfstudio.guidenh.client.hotkey; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.settings.KeyBinding; + +import org.lwjgl.input.Keyboard; + +import com.hfstudio.guidenh.guide.internal.GuideScreen; + +import cpw.mods.fml.client.registry.ClientRegistry; +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 GuidePageHistoryHotkey { + + public static final KeyBinding GUIDE_PAGE_BACK_KEY = new KeyBinding( + "key.guidenh.guide_page_back", + Keyboard.KEY_NONE, + "key.categories.guidenh"); + + public static final KeyBinding GUIDE_PAGE_FORWARD_KEY = new KeyBinding( + "key.guidenh.guide_page_forward", + Keyboard.KEY_NONE, + "key.categories.guidenh"); + + private GuidePageHistoryHotkey() {} + + public static void init() { + ClientRegistry.registerKeyBinding(GUIDE_PAGE_BACK_KEY); + ClientRegistry.registerKeyBinding(GUIDE_PAGE_FORWARD_KEY); + FMLCommonHandler.instance() + .bus() + .register(new GuidePageHistoryHotkey()); + } + + @SubscribeEvent + public void onClientTick(TickEvent.ClientTickEvent event) { + if (event.phase != TickEvent.Phase.END) { + return; + } + + if (!(Minecraft.getMinecraft().currentScreen instanceof GuideScreen guideScreen)) { + return; + } + + while (GUIDE_PAGE_BACK_KEY.isPressed()) { + guideScreen.navigateBackFromHotkey(); + } + + while (GUIDE_PAGE_FORWARD_KEY.isPressed()) { + guideScreen.navigateForwardFromHotkey(); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/color/Colors.java b/src/main/java/com/hfstudio/guidenh/guide/color/Colors.java index bc179c08..eac6605f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/color/Colors.java +++ b/src/main/java/com/hfstudio/guidenh/guide/color/Colors.java @@ -1,6 +1,6 @@ package com.hfstudio.guidenh.guide.color; -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class Colors { @@ -56,8 +56,7 @@ public static int hexToRgb(String hexColor) { } } - FMLLog.getLogger() - .error("[GuideNH] [Colors] Tried to parse an invalid hexadecimal color string: '{}'", hexColor); + GuideDebugLog.error("[GuideNH] [Colors] Tried to parse an invalid hexadecimal color string: '{}'", hexColor); return 0; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/Frontmatter.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/Frontmatter.java index c6f27cee..1a72156e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/Frontmatter.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/Frontmatter.java @@ -2,6 +2,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -37,7 +38,23 @@ public static Frontmatter parse(ResourceLocation pageId, String yamlText) { var yaml = YAML.get(); FrontmatterNavigation navigation = null; - Map data = yaml.load(yamlText); + Object loaded = yaml.load(yamlText); + if (loaded == null) { + return new Frontmatter(null, Collections.emptyMap()); + } + if (!(loaded instanceof MaploadedMap)) { + throw new IllegalArgumentException("Frontmatter root has to be a map"); + } + + Map data = new HashMap<>(); + for (Map.Entry entry : loadedMap.entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String)) { + throw new IllegalArgumentException("Frontmatter keys have to be strings"); + } + data.put((String) key, entry.getValue()); + } + var navigationObj = data.remove("navigation"); if (navigationObj != null) { if (!(navigationObj instanceof MapnavigationMap)) { 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 de383c4b..3d17af8f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideItemReferenceResolver.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideItemReferenceResolver.java @@ -6,6 +6,7 @@ import net.minecraft.init.Blocks; import net.minecraft.item.Item; import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; import net.minecraftforge.oredict.OreDictionary; @@ -47,7 +48,7 @@ public static ResolvedItemReference resolveItemReference(String defaultNamespace ItemStack stack = new ItemStack(item, 1, ref.concreteMeta()); if (ref.nbt() != null) { - stack.stackTagCompound = (net.minecraft.nbt.NBTTagCompound) ref.nbt() + stack.stackTagCompound = (NBTTagCompound) ref.nbt() .copy(); } return new ResolvedItemReference(ref.id(), stack); @@ -89,7 +90,7 @@ public static ResolvedBlockReference resolveBlockReference(String defaultNamespa Item item = Item.getItemFromBlock(block); ItemStack stack = item != null ? new ItemStack(item, 1, ref.hasExplicitMeta() ? ref.meta() : 0) : null; if (stack != null && ref.nbt() != null) { - stack.stackTagCompound = (net.minecraft.nbt.NBTTagCompound) ref.nbt() + stack.stackTagCompound = (NBTTagCompound) ref.nbt() .copy(); } return new ResolvedBlockReference(ref.id(), block, stack, ref.hasExplicitMeta(), ref.meta()); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideMarkdownDefinitions.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideMarkdownDefinitions.java index 57c63608..bfe6e0bd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideMarkdownDefinitions.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/GuideMarkdownDefinitions.java @@ -6,6 +6,7 @@ import org.jetbrains.annotations.Nullable; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; import com.hfstudio.guidenh.libs.mdast.model.MdAstDefinition; import com.hfstudio.guidenh.libs.mdast.model.MdAstParent; @@ -21,9 +22,23 @@ public static Map collect(MdAstParent parent) { public static Map collect(List children) { Map definitions = new HashMap<>(); for (MdAstAnyContent child : children) { - if (child instanceof MdAstDefinition definition && definition.identifier != null) { + // Pre-conversion: raw MdAstDefinition + if (child instanceof MdAstDefinition && ((MdAstDefinition) child).identifier != null) { + MdAstDefinition definition = (MdAstDefinition) child; definitions.putIfAbsent(definition.identifier, definition); } + // Post-conversion: element + if (child instanceof MdxJsxFlowElement && "definition".equals(((MdxJsxFlowElement) child).name())) { + MdxJsxFlowElement el = (MdxJsxFlowElement) child; + String identifier = el.getAttributeString("identifier", null); + if (identifier != null) { + MdAstDefinition def = new MdAstDefinition(); + def.identifier = identifier; + def.url = el.getAttributeString("url", ""); + def.title = el.getAttributeString("title", ""); + definitions.putIfAbsent(identifier, def); + } + } } return definitions; } 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 14f190f7..e5dd922d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/IdUtils.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/IdUtils.java @@ -18,8 +18,7 @@ import com.github.bsideup.jabel.Desugar; import com.gtnewhorizon.gtnhlib.util.data.ItemId; import com.hfstudio.guidenh.guide.internal.structure.GuideTextNbtCodec; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class IdUtils { @@ -107,12 +106,11 @@ public static ParsedItemRef parseItemRef(String idText, String defaultNamespace) nbt = tc; } } catch (Throwable t) { - FMLLog.getLogger() - .warn( - "[GuideNH] [IdUtils] Failed to parse SNBT tail '{}' for id '{}'; ignoring NBT", - snbt, - idText, - t); + GuideDebugLog.warnAlways( + "[GuideNH] [IdUtils] Failed to parse SNBT tail '{}' for id '{}'; ignoring NBT", + snbt, + idText, + t); } } else { head = idText; diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/MdxBlockTagSourceExtractor.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/MdxBlockTagSourceExtractor.java index 2af920e3..79b7069c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/MdxBlockTagSourceExtractor.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/MdxBlockTagSourceExtractor.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.unist.UnistPoint; import com.hfstudio.guidenh.libs.unist.UnistPosition; public class MdxBlockTagSourceExtractor { @@ -24,13 +25,7 @@ private MdxBlockTagSourceExtractor() {} return null; } - int sourceStart = position.start() - .offset(); - if (sourceStart < 0 || sourceStart >= sourceText.length()) { - return null; - } - - int openingTagStart = findOpeningTagStart(sourceText, sourceStart, tagName); + int openingTagStart = findOpeningTagStart(sourceText, position, tagName); if (openingTagStart < 0) { return null; } @@ -50,11 +45,80 @@ private MdxBlockTagSourceExtractor() {} return sourceText.substring(openingTagEnd + 1, closingTagStart); } - private static int findOpeningTagStart(String sourceText, int sourceStart, String tagName) { + private static int findOpeningTagStart(String sourceText, UnistPosition position, String tagName) { + UnistPoint start = position.start(); + if (start == null) { + return -1; + } + + // Preprocessors can shift offsets while still keeping line and column stable. + int lineBasedStart = findOpeningTagStartOnDeclaredLine(sourceText, start, tagName); + if (lineBasedStart >= 0) { + return lineBasedStart; + } + + int sourceStart = start.offset(); + if (sourceStart < 0 || sourceStart >= sourceText.length()) { + return -1; + } if (matchesOpeningTagName(sourceText, sourceStart, tagName)) { return sourceStart; } + return findOpeningTagStartByOffset(sourceText, sourceStart, tagName); + } + + private static int findOpeningTagStartOnDeclaredLine(String sourceText, UnistPoint start, String tagName) { + int lineStart = findLineStartOffset(sourceText, start.line()); + if (lineStart < 0) { + return -1; + } + + int lineEnd = sourceText.indexOf('\n', lineStart); + if (lineEnd < 0) { + lineEnd = sourceText.length(); + } + + int declaredOffset = lineStart + Math.max(0, start.column() - 1); + if (declaredOffset < lineEnd && matchesOpeningTagName(sourceText, declaredOffset, tagName)) { + return declaredOffset; + } + + for (int index = Math.min(declaredOffset, lineEnd - 1); index < lineEnd; index++) { + if (sourceText.charAt(index) == '<' && matchesOpeningTagName(sourceText, index, tagName)) { + return index; + } + } + + for (int searchStart = Math.min(declaredOffset - 1, lineEnd - 1); searchStart >= lineStart;) { + int candidate = sourceText.lastIndexOf('<', searchStart); + if (candidate < lineStart) { + break; + } + if (matchesOpeningTagName(sourceText, candidate, tagName)) { + return candidate; + } + searchStart = candidate - 1; + } + return -1; + } + + private static int findLineStartOffset(String sourceText, int lineNumber) { + if (lineNumber <= 0) { + return -1; + } + + int currentLine = 1; + int index = 0; + while (currentLine < lineNumber && index < sourceText.length()) { + if (sourceText.charAt(index++) == '\n') { + currentLine++; + } + } + return currentLine == lineNumber ? index : -1; + } + + private static int findOpeningTagStartByOffset(String sourceText, int sourceStart, String tagName) { int searchStart = sourceStart; while (searchStart >= 0) { int candidate = sourceText.lastIndexOf('<', searchStart); 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 5c7dd5a3..ee6e47e7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/PageCompiler.java @@ -3,10 +3,10 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; -import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; @@ -16,46 +16,30 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import net.minecraft.item.ItemStack; import net.minecraft.util.ResourceLocation; import org.jetbrains.annotations.Nullable; import com.github.bsideup.jabel.Desugar; -import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.GuidePage; -import com.hfstudio.guidenh.guide.PageAnchor; import com.hfstudio.guidenh.guide.PageCollection; import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.color.SymbolicColor; import com.hfstudio.guidenh.guide.compiler.tags.CsvTableCompiler; -import com.hfstudio.guidenh.guide.compiler.tags.functiongraph.FunctionGraphFenceParser; -import com.hfstudio.guidenh.guide.document.LytErrorSink; +import com.hfstudio.guidenh.guide.compiler.tags.DetailsContentExtractor; import com.hfstudio.guidenh.guide.document.block.LatexRenderOptions; import com.hfstudio.guidenh.guide.document.block.LatexVerticalAlign; -import com.hfstudio.guidenh.guide.document.block.LytAlertBox; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; -import com.hfstudio.guidenh.guide.document.block.LytCodeBlock; import com.hfstudio.guidenh.guide.document.block.LytDocument; import com.hfstudio.guidenh.guide.document.block.LytDocumentFloat; import com.hfstudio.guidenh.guide.document.block.LytFloatAwareBlock; import com.hfstudio.guidenh.guide.document.block.LytHeading; -import com.hfstudio.guidenh.guide.document.block.LytImage; -import com.hfstudio.guidenh.guide.document.block.LytItemImage; import com.hfstudio.guidenh.guide.document.block.LytLatexBlock; import com.hfstudio.guidenh.guide.document.block.LytLatexDisplayBlock; -import com.hfstudio.guidenh.guide.document.block.LytList; import com.hfstudio.guidenh.guide.document.block.LytListItem; -import com.hfstudio.guidenh.guide.document.block.LytMermaidMindmap; -import com.hfstudio.guidenh.guide.document.block.LytNode; import com.hfstudio.guidenh.guide.document.block.LytParagraph; -import com.hfstudio.guidenh.guide.document.block.LytQuoteBox; -import com.hfstudio.guidenh.guide.document.block.LytTaskListItem; -import com.hfstudio.guidenh.guide.document.block.LytThematicBreak; -import com.hfstudio.guidenh.guide.document.block.LytVBox; import com.hfstudio.guidenh.guide.document.block.table.LytTable; -import com.hfstudio.guidenh.guide.document.flow.LytFlowBreak; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowLink; @@ -63,35 +47,21 @@ import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; import com.hfstudio.guidenh.guide.document.flow.LytFlowText; import com.hfstudio.guidenh.guide.document.flow.LytSpoilerSpan; -import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.guide.extensions.Extension; import com.hfstudio.guidenh.guide.extensions.ExtensionCollection; import com.hfstudio.guidenh.guide.extensions.ExtensionPoint; import com.hfstudio.guidenh.guide.indices.PageIndex; import com.hfstudio.guidenh.guide.internal.GuideRegistry; -import com.hfstudio.guidenh.guide.internal.csv.CsvTableParser; -import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockLanguage; -import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockLanguageDetector; -import com.hfstudio.guidenh.guide.internal.markdown.FileTreeCompiler; import com.hfstudio.guidenh.guide.internal.markdown.FootnotePreprocessor; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownActionLink; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownHtmlRuntimeNormalizer; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownLatexShorthand; -import com.hfstudio.guidenh.guide.internal.markdown.MarkdownListSemantics; import com.hfstudio.guidenh.guide.internal.markdown.MarkdownLiteralAutolink; -import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks; -import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.BlockquoteDirective; -import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.QuoteIconKind; -import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.QuoteIconSpec; -import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapParser; +import com.hfstudio.guidenh.guide.internal.markdown.MdAstToMdxConverter; import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; import com.hfstudio.guidenh.guide.internal.util.LangUtil; -import com.hfstudio.guidenh.guide.mediawiki.MediaWikiListContext; -import com.hfstudio.guidenh.guide.mediawiki.MediaWikiListContextProvider; -import com.hfstudio.guidenh.guide.mediawiki.MediaWikiPageIds; -import com.hfstudio.guidenh.guide.render.GuidePageTexture; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.sound.GuideSoundParsers; -import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.style.TextAlignment; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; import com.hfstudio.guidenh.libs.mdast.MdAst; @@ -99,46 +69,23 @@ import com.hfstudio.guidenh.libs.mdast.MdastOptions; import com.hfstudio.guidenh.libs.mdast.gfm.model.GfmTable; import com.hfstudio.guidenh.libs.mdast.gfm.model.GfmTableRow; -import com.hfstudio.guidenh.libs.mdast.gfmstrikethrough.MdAstDelete; -import com.hfstudio.guidenh.libs.mdast.guidemark.MdAstMark; -import com.hfstudio.guidenh.libs.mdast.guideunderline.MdAstDottedUnderline; -import com.hfstudio.guidenh.libs.mdast.guideunderline.MdAstUnderline; -import com.hfstudio.guidenh.libs.mdast.guideunderline.MdAstWavyUnderline; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; 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.MdAstBreak; -import com.hfstudio.guidenh.libs.mdast.model.MdAstCode; import com.hfstudio.guidenh.libs.mdast.model.MdAstDefinition; -import com.hfstudio.guidenh.libs.mdast.model.MdAstEmphasis; -import com.hfstudio.guidenh.libs.mdast.model.MdAstHTML; -import com.hfstudio.guidenh.libs.mdast.model.MdAstHeading; -import com.hfstudio.guidenh.libs.mdast.model.MdAstImage; -import com.hfstudio.guidenh.libs.mdast.model.MdAstImageReference; -import com.hfstudio.guidenh.libs.mdast.model.MdAstInlineCode; -import com.hfstudio.guidenh.libs.mdast.model.MdAstLink; -import com.hfstudio.guidenh.libs.mdast.model.MdAstLinkReference; -import com.hfstudio.guidenh.libs.mdast.model.MdAstList; -import com.hfstudio.guidenh.libs.mdast.model.MdAstListItem; import com.hfstudio.guidenh.libs.mdast.model.MdAstNode; import com.hfstudio.guidenh.libs.mdast.model.MdAstParagraph; import com.hfstudio.guidenh.libs.mdast.model.MdAstParent; -import com.hfstudio.guidenh.libs.mdast.model.MdAstPhrasingContent; import com.hfstudio.guidenh.libs.mdast.model.MdAstPosition; import com.hfstudio.guidenh.libs.mdast.model.MdAstRoot; -import com.hfstudio.guidenh.libs.mdast.model.MdAstStrong; import com.hfstudio.guidenh.libs.mdast.model.MdAstText; -import com.hfstudio.guidenh.libs.mdast.model.MdAstThematicBreak; import com.hfstudio.guidenh.libs.mdx.MdxCommentMasker; import com.hfstudio.guidenh.libs.micromark.ParseException; import com.hfstudio.guidenh.libs.unist.UnistNode; import com.hfstudio.guidenh.libs.unist.UnistPoint; import com.hfstudio.guidenh.libs.unist.UnistPosition; -import cpw.mods.fml.common.FMLLog; - public class PageCompiler { /** @@ -146,16 +93,13 @@ public class PageCompiler { */ public static final int DEFAULT_ELEMENT_SPACING = 5; public static final MdastOptions PARSE_OPTIONS = GuideMarkdownOptions.runtime(); - private static final Pattern CODEBLOCK_META_WIDTH = Pattern.compile("(^|\\s)width=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); public static final int DEFAULT_MARK_BACKGROUND_COLOR = 0xFF8A6A00; private static final Pattern TABLE_ATTRIBUTE_LINE = Pattern.compile("^\\{:\\s*(.+?)\\s*}$"); - private static final Pattern CODEBLOCK_META_HEIGHT = Pattern - .compile("(^|\\s)height=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); private static PageLinkResolver pageLinkResolver = PageCompiler::defaultPageExistsForLink; private static final State> SOURCE_SLICE_STACK = new State<>( "source_slice_stack", castClass(List.class), - List.of()); + Collections.emptyList()); private final PageCollection pages; private final ExtensionCollection extensions; @@ -219,6 +163,7 @@ public static ParsedGuidePage parse(String sourcePack, ResourceLocation id, Stri } public static ParsedGuidePage parse(String sourcePack, String language, ResourceLocation id, String pageContent) { + pageContent = pageContent != null ? pageContent : ""; long parseStartedAt = System.nanoTime(); long stageStartedAt = parseStartedAt; pageContent = normalizeLineEndings(pageContent); @@ -244,9 +189,11 @@ public static ParsedGuidePage parse(String sourcePack, String language, Resource String parseFailureMessage = null; UnistPoint parseFailureFrom = null; UnistPoint parseFailureTo = null; - long markdownParseNs; + Frontmatter frontmatter; + long markdownParseNs = 0L; long latexRestoreNs = 0L; long htmlNormalizeNs = 0L; + long mdAstConvertNs = 0L; try { stageStartedAt = System.nanoTime(); astRoot = MdAst.fromMarkdown(parseContent, PARSE_OPTIONS); @@ -259,58 +206,53 @@ public static ParsedGuidePage parse(String sourcePack, String language, Resource stageStartedAt = System.nanoTime(); MarkdownHtmlRuntimeNormalizer.normalize(astRoot); htmlNormalizeNs = System.nanoTime() - stageStartedAt; - } catch (ParseException e) { - markdownParseNs = System.nanoTime() - stageStartedAt; - parseFailureFrom = e.getFrom(); - parseFailureTo = e.getTo(); - var position = ""; - if (parseFailureFrom != null) { - position = " at line " + e.getFrom() - .line() - + " column " - + e.getFrom() - .column(); + + // Collect definitions before conversion (converter needs them + // for link/image reference resolution). + stageStartedAt = System.nanoTime(); + Map definitions = GuideMarkdownDefinitions.collect(astRoot); + + // Parse frontmatter BEFORE conversion — the converter removes + // MdAstYamlFrontmatter from children. + frontmatter = parseFrontmatter(id, astRoot); + + MdAstToMdxConverter.convert(astRoot, definitions); + mdAstConvertNs = System.nanoTime() - stageStartedAt; + } catch (RuntimeException t) { + if (t instanceof ParseException e) { + markdownParseNs = System.nanoTime() - stageStartedAt; + parseFailureFrom = e.getFrom(); + parseFailureTo = e.getTo(); } - var errorMessage = String.format( - Locale.ROOT, - "Failed to parse GuideME page %s (lang: %s)%s from resource pack %s", - id, - language, - position, - sourcePack); - FMLLog.getLogger() - .error("[GuideNH] [PageCompiler] {}", errorMessage, e); - parseFailureMessage = errorMessage + ": \n" + e; + String errorMessage = formatParseFailureMessage(id, language, sourcePack, parseFailureFrom); + GuideDebugLog.error("[GuideNH] [PageCompiler] {}", errorMessage, t); + parseFailureMessage = errorMessage + ": \n" + t; astRoot = buildErrorPage(parseFailureMessage); + frontmatter = new Frontmatter(null, Collections.emptyMap()); } - // Find front-matter - stageStartedAt = System.nanoTime(); - var frontmatter = parseFrontmatter(id, astRoot); long astFrontmatterNs = System.nanoTime() - stageStartedAt; if (parseFailureMessage != null && sourceFrontmatter.navigationEntry() != null) { frontmatter = sourceFrontmatter; } long totalNs = System.nanoTime() - parseStartedAt; - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [PageCompiler] Parsed page {} lang={} totalNs={} normalizeNs={} footnoteNs={} sourceFrontmatterNs={} latexMaskNs={} commentMaskNs={} markdownParseNs={} latexRestoreNs={} htmlNormalizeNs={} astFrontmatterNs={} parseFailed={}", - id, - language, - totalNs, - normalizeNs, - footnoteNs, - sourceFrontmatterNs, - latexMaskNs, - commentMaskNs, - markdownParseNs, - latexRestoreNs, - htmlNormalizeNs, - astFrontmatterNs, - parseFailureMessage != null); - } + GuideDebugLog.info( + "[GuideNH] [PageCompiler] Parsed page {} lang={} totalNs={} normalizeNs={} footnoteNs={} sourceFrontmatterNs={} latexMaskNs={} commentMaskNs={} markdownParseNs={} latexRestoreNs={} htmlNormalizeNs={} mdAstConvertNs={} astFrontmatterNs={} parseFailed={}", + id, + language, + totalNs, + normalizeNs, + footnoteNs, + sourceFrontmatterNs, + latexMaskNs, + commentMaskNs, + markdownParseNs, + latexRestoreNs, + htmlNormalizeNs, + mdAstConvertNs, + astFrontmatterNs, + parseFailureMessage != null); return new ParsedGuidePage( sourcePack, @@ -324,10 +266,53 @@ public static ParsedGuidePage parse(String sourcePack, String language, Resource parseFailureTo); } + /** + * Lightweight parse that extracts only YAML frontmatter from the raw source, + * deferring the full Micromark → mdast pipeline to first call of + * {@link ParsedGuidePage#getAstRoot()}. + * + *

+ * F3+T reload uses this path so that index/navigation rebuilds — + * which only need frontmatter — complete without paying Micromark cost. + *

+ */ + public static ParsedGuidePage parseFrontmatterOnly(String sourcePack, String language, ResourceLocation id, + String pageContent) { + pageContent = pageContent != null ? pageContent : ""; + pageContent = normalizeLineEndings(pageContent); + var sourceFrontmatter = parseFrontmatterFromSource(id, pageContent); + + return new ParsedGuidePage( + sourcePack, + id, + pageContent, + null, // astRoot — triggers lazy parse on first getAstRoot() + sourceFrontmatter, + language, + null, // no parse failure yet + null, + null); + } + public static String normalizeLineEndings(String pageContent) { return GuideStringLines.normalizeLineEndings(pageContent); } + private static String formatParseFailureMessage(ResourceLocation id, String language, String sourcePack, + @Nullable UnistPoint position) { + String positionText = ""; + if (position != null) { + positionText = " at line " + position.line() + " column " + position.column(); + } + return String.format( + Locale.ROOT, + "Failed to parse GuideME page %s (lang: %s)%s from resource pack %s", + id, + language, + positionText, + sourcePack); + } + public static MdAstRoot buildErrorPage(String errorText) { return buildErrorPage("PARSING ERROR", errorText); } @@ -335,17 +320,17 @@ public static MdAstRoot buildErrorPage(String errorText) { public static MdAstRoot buildErrorPage(String headingText, String errorText) { var root = new MdAstRoot(); - var heading = new MdAstHeading(); + var heading = new MdxJsxFlowElement(); + heading.setName("h1"); + heading.addAttribute("depth", 1); root.addChild(heading); - - heading.depth = 1; var headingTextNode = new MdAstText(); headingTextNode.setValue(headingText); heading.addChild(headingTextNode); - var errorParagraph = new MdAstParagraph(); + var errorParagraph = new MdxJsxFlowElement(); + errorParagraph.setName("p"); root.addChild(errorParagraph); - var errorTextNode = new MdAstText(); errorTextNode.setValue(errorText); errorParagraph.addChild(errorTextNode); @@ -421,35 +406,36 @@ public static Frontmatter parseFrontmatter(ResourceLocation pageId, MdAstRoot ro for (var child : root.children()) { if (child instanceof MdAstYamlFrontmatter frontmatter) { if (result != null) { - FMLLog.getLogger() - .error("[GuideNH] [PageCompiler] Found more than one frontmatter!"); + GuideDebugLog.error("[GuideNH] [PageCompiler] Found more than one frontmatter!"); continue; } try { result = Frontmatter.parse(pageId, frontmatter.value); } catch (Exception e) { - FMLLog.getLogger() - .error("[GuideNH] [PageCompiler] Failed to parse frontmatter for page {}", pageId, e); + GuideDebugLog.error("[GuideNH] [PageCompiler] Failed to parse frontmatter for page {}", pageId, e); break; } } } - return result != null ? result : new Frontmatter(null, Map.of()); + return result != null ? result : new Frontmatter(null, Collections.emptyMap()); } public static Frontmatter parseFrontmatterFromSource(ResourceLocation pageId, String pageContent) { + // Strip UTF-8 BOM if present (resource pack files may include it) + if (pageContent.startsWith("")) { + pageContent = pageContent.substring(1); + } var yamlText = extractFrontmatterText(pageContent); if (yamlText == null) { - return new Frontmatter(null, Map.of()); + return new Frontmatter(null, Collections.emptyMap()); } try { return Frontmatter.parse(pageId, yamlText); } catch (Exception e) { - FMLLog.getLogger() - .error("[GuideNH] [PageCompiler] Failed to parse frontmatter for page {}", pageId, e); - return new Frontmatter(null, Map.of()); + GuideDebugLog.error("[GuideNH] [PageCompiler] Failed to parse frontmatter for page {}", pageId, e); + return new Frontmatter(null, Collections.emptyMap()); } } @@ -544,11 +530,30 @@ public void compileInlineMarkdown(String source, LytFlowParent layoutParent) { layoutParent); } + public void compileBlockMarkdown(String source, LytBlockContainer layoutParent) { + if (source == null || source.isEmpty()) { + return; + } + ParsedGuidePage parsed = parse(sourcePack, language, pageId, source); + Map previousDefinitions = new HashMap<>(definitions); + definitions.putAll(GuideMarkdownDefinitions.collect(parsed.getAstRoot())); + try { + withSourceSlice(source, () -> compileBlockContext(parsed.getAstRoot(), layoutParent)); + } finally { + definitions.clear(); + definitions.putAll(previousDefinitions); + } + } + public void compileInlineFragment(Collection children, LytFlowParent layoutParent) { for (MdAstAnyContent child : children) { - if (child instanceof MdAstParagraph paragraph) { - compileFlowContext(paragraph, layoutParent); - } else if (child instanceof MdAstParentnestedParent && !(child instanceof MdAstPhrasingContent)) { + if (child instanceof MdxJsxFlowElement el && "p".equals(el.name())) { + compileFlowContext(el, layoutParent); + } else if (child instanceof MdxJsxFlowElement el) { + for (var nestedChild : el.children()) { + compileFlowContent(layoutParent, nestedChild); + } + } else if (child instanceof MdAstParentnestedParent) { for (var nestedChild : nestedParent.children()) { compileFlowContent(layoutParent, nestedChild); } @@ -558,6 +563,21 @@ public void compileInlineFragment(Collection children } } + public void compileTableCellContent(MdAstParent markdownParent, LytBlockContainer layoutParent) { + compileTableCellContent(markdownParent.children(), layoutParent); + } + + public void compileTableCellContent(List children, LytBlockContainer layoutParent) { + var paragraph = new LytParagraph(); + paragraph.setMarginTop(0); + paragraph.setMarginBottom(0); + withChildrenSourceContext(children, () -> compileInlineFragment(children, paragraph)); + if (paragraph.isEmpty()) { + return; + } + layoutParent.append(paragraph); + } + public void compileBlockContextInSourceContext(List children, LytBlockContainer layoutParent) { withChildrenSourceContext(children, () -> compileBlockContext(children, layoutParent)); @@ -584,56 +604,56 @@ public void compileBlockContext(List children, LytBlo LytBlock previousLayoutChild = null; for (int i = 0; i < children.size(); i++) { var child = children.get(i); - LytBlock layoutChild; - if (child instanceof MdAstThematicBreak) { - layoutChild = new LytThematicBreak(); - } else if (child instanceof MdAstList astList) { - layoutChild = compileList(astList); - } else if (child instanceof MdAstCode astCode) { - layoutChild = compileCodeBlock(astCode); - } else if (child instanceof MdAstHeading astHeading) { - var heading = new LytHeading(); - heading.setDepth(astHeading.depth); - compileFlowContext(astHeading, heading); - layoutChild = heading; - } else if (child instanceof MdAstBlockquote astBlockquote) { - layoutChild = compileBlockquote(astBlockquote); - } else if (child instanceof MdAstParagraph astParagraph) { - var paragraph = new LytParagraph(); - compileFlowContext(astParagraph, paragraph); - paragraph.setMarginTop(DEFAULT_ELEMENT_SPACING); - paragraph.setMarginBottom(DEFAULT_ELEMENT_SPACING); - layoutChild = paragraph; - } else if (child instanceof MdAstYamlFrontmatter || child instanceof MdAstDefinition) { - layoutChild = null; - } else if (child instanceof GfmTable astTable) { - MarkdownTableMeta meta = extractMarkdownTableMeta(children, i + 1); - layoutChild = compileTable(astTable, meta.widthHints()); - if (meta.consumeChildCount() > 0) { - i += meta.consumeChildCount(); + LytBlock layoutChild = null; + + if (child instanceof MdxJsxFlowElement el) { + // Definition elements are metadata, not rendered + if ("definition".equals(el.name())) { + layoutChild = null; + } else { + var compiler = tagCompilers.get(el.name()); + if (compiler == null) { + layoutChild = createErrorBlock("Unhandled MDX element in block context: " + el.name(), child); + } else { + layoutChild = null; + compiler.compileBlockContext(this, layoutParent, el); + } + } + } else if (child instanceof MdxJsxTextElement el) { + // Inline element at block level — merge into previous paragraph when possible + if (previousLayoutChild instanceof LytParagraph paragraph) { + var flowCompiler = tagCompilers.get(el.name()); + if (flowCompiler != null) { + flowCompiler.compileFlowContext(this, paragraph, el); + } + continue; } - } else if (child instanceof MdAstHTML astHtml) { var paragraph = new LytParagraph(); - compileHtmlLiteral(paragraph, astHtml.value); - layoutChild = paragraph; - } else if (child instanceof MdxJsxFlowElement el) { - var compiler = tagCompilers.get(el.name()); - if (compiler == null) { - layoutChild = createErrorBlock("Unhandled MDX element in block context", child); - } else { - layoutChild = null; - compiler.compileBlockContext(this, layoutParent, el); + var flowCompiler = tagCompilers.get(el.name()); + if (flowCompiler != null) { + flowCompiler.compileFlowContext(this, paragraph, el); } - } else if (child instanceof MdAstPhrasingContent phrasingContent) { + layoutChild = paragraph; + } else if (child instanceof MdAstText text) { + // Orphan text — merge into previous paragraph when possible if (previousLayoutChild instanceof LytParagraph paragraph) { - compileFlowContent(paragraph, phrasingContent); + var flowText = new LytFlowText(); + flowText.setText(text.value); + paragraph.append(flowText); continue; } var paragraph = new LytParagraph(); - compileFlowContent(paragraph, phrasingContent); + var flowText = new LytFlowText(); + flowText.setText(text.value); + paragraph.append(flowText); layoutChild = paragraph; + } else if (child instanceof MdAstDefinition) { + layoutChild = null; // handled via element } else { - layoutChild = createErrorBlock("Unhandled Markdown node in block context", child); + layoutChild = createErrorBlock( + "Unhandled node in block context: " + child.getClass() + .getSimpleName(), + child); } if (layoutChild != null) { @@ -647,202 +667,6 @@ public void compileBlockContext(List children, LytBlo } } - private LytList compileList(MdAstList astList) { - var list = new LytList(astList.ordered, astList.start); - for (var listContent : astList.children()) { - if (listContent instanceof MdAstListItem astListItem) { - var taskMarker = MarkdownListSemantics.extractTaskMarker(astListItem.children()); - LytListItem listItem = taskMarker != null ? new LytTaskListItem() : new LytListItem(); - if (listItem instanceof LytTaskListItem taskListItem) { - taskListItem.setChecked(taskMarker.checked()); - } - compileListItem(astListItem, listItem, taskMarker); - - // Fix up top/bottom margin for list item children - var children = listItem.getChildren(); - if (!children.isEmpty()) { - var firstChild = children.get(0); - if (firstChild instanceof LytBlock firstBlock) { - firstBlock.setMarginTop(0); - firstBlock.setMarginBottom(0); - } - } - list.append(listItem); - } else { - list.append(createErrorBlock("Cannot handle list content", listContent)); - } - } - return list; - } - - private LytBlock compileBlockquote(MdAstBlockquote astBlockquote) { - BlockquoteDirective directive = MarkdownRuntimeBlocks.parseBlockquoteDirective(astBlockquote); - if (directive != null) { - if (directive.alertType() != null) { - var alertBox = new LytAlertBox(); - alertBox.setTitle( - directive.alertType() - .displayText(), - directive.alertType()); - alertBox.setMarginTop(DEFAULT_ELEMENT_SPACING); - alertBox.setMarginBottom(DEFAULT_ELEMENT_SPACING); - compileDirectiveBody(directive, alertBox); - normalizeBlockMargins(alertBox); - return wrapFloatAwareIfNeeded(alertBox); - } - - var quoteBox = new LytQuoteBox(); - quoteBox.setQuoteStyle(directive.accentColor(), directive.title(), buildQuoteIcon(directive.icon())); - quoteBox.setMarginTop(DEFAULT_ELEMENT_SPACING); - quoteBox.setMarginBottom(DEFAULT_ELEMENT_SPACING); - compileDirectiveBody(directive, quoteBox); - normalizeBlockMargins(quoteBox); - shiftFirstParagraphDown(quoteBox, 1); - return wrapFloatAwareIfNeeded(quoteBox); - } - - var blockquote = new LytVBox(); - blockquote.setBackgroundColor(SymbolicColor.BLOCKQUOTE_BACKGROUND); - blockquote.setPadding(5); - blockquote.setPaddingLeft(10); - blockquote.setBorderLeft(new BorderStyle(SymbolicColor.TABLE_BORDER, 2)); - blockquote.setMarginTop(DEFAULT_ELEMENT_SPACING); - blockquote.setMarginBottom(DEFAULT_ELEMENT_SPACING); - compileBlockContext(astBlockquote, blockquote); - normalizeBlockMargins(blockquote); - shiftFirstParagraphDown(blockquote, 1); - return wrapFloatAwareIfNeeded(blockquote); - } - - private void normalizeBlockMargins(LytNode box) { - var boxChildren = box.getChildren(); - if (!boxChildren.isEmpty()) { - if (boxChildren.get(0) instanceof LytParagraph firstParagraph) { - firstParagraph.setMarginTop(0); - } - if (boxChildren.get(boxChildren.size() - 1) instanceof LytParagraph lastParagraph) { - lastParagraph.setMarginBottom(0); - } - } - } - - private void shiftFirstParagraphDown(LytNode box, int pixels) { - var boxChildren = box.getChildren(); - if (!boxChildren.isEmpty() && boxChildren.get(0) instanceof LytParagraph firstParagraph) { - firstParagraph.setPaddingTop(firstParagraph.getPaddingTop() + pixels); - } - } - - private void compileListItem(MdAstListItem astListItem, LytListItem listItem, - @Nullable MarkdownListSemantics.TaskMarker taskMarker) { - if (taskMarker == null || astListItem.children() - .isEmpty() - || !(astListItem.children() - .get(0) instanceof MdAstParagraph paragraph)) { - compileBlockContext(astListItem, listItem); - return; - } - - var paragraphCopy = cloneParagraphWithLeadingTextOverride(paragraph, taskMarker.remainingText()); - compileParagraphBlock(paragraphCopy, listItem); - for (int i = 1; i < astListItem.children() - .size(); i++) { - var child = astListItem.children() - .get(i); - compileBlockContext(List.of(child), listItem); - } - } - - private void compileDirectiveBody(BlockquoteDirective directive, LytBlockContainer parent) { - List children = directive.children(); - if (!children.isEmpty() && directive.firstParagraph() != null - && children.get(0) == directive.firstParagraph()) { - MdAstParagraph firstParagraph = cloneParagraphWithLeadingTextOverride( - directive.firstParagraph(), - directive.remainingText()); - if (!firstParagraph.children() - .isEmpty()) { - compileParagraphBlock(firstParagraph, parent); - } - for (int i = 1; i < children.size(); i++) { - compileBlockContext(List.of(children.get(i)), parent); - } - return; - } - compileBlockContext(children, parent); - } - - private MdAstParagraph cloneParagraphWithLeadingTextOverride(MdAstParagraph original, String leadingText) { - MdAstParagraph copy = new MdAstParagraph(); - boolean replaced = false; - for (var child : original.children()) { - if (!replaced && child instanceof MdAstText) { - if (leadingText != null && !leadingText.isEmpty()) { - MdAstText text = new MdAstText(); - text.setValue(leadingText); - copy.addChild(text); - } - replaced = true; - continue; - } - if (child instanceof MdAstNode astNode) { - copy.addChild(astNode); - } - } - return copy; - } - - private @Nullable LytFlowContent buildQuoteIcon(@Nullable QuoteIconSpec icon) { - if (icon == null || icon.value() == null - || icon.value() - .trim() - .isEmpty()) { - return null; - } - - if (icon.kind() == QuoteIconKind.ITEM) { - ItemStack stack = IdUtils.resolveItemStack( - icon.value() - .trim(), - pageId.getResourceDomain()); - if (stack == null) { - return null; - } - var itemImage = new LytItemImage(stack); - itemImage.setInline(true); - itemImage.setTooltipSuppressed(true); - itemImage.setInlineYOffsetOverride(-1); - return LytFlowInlineBlock.of(itemImage); - } - - if (icon.kind() == QuoteIconKind.PNG) { - try { - ResourceLocation imageId = IdUtils.resolveLink( - icon.value() - .trim(), - pageId); - byte[] imageData = loadAsset(imageId); - if (imageData == null) { - return null; - } - var image = new LytImage(); - image.setTexture(imageId, GuidePageTexture.load(imageId, imageData)); - image.setExplicitWidth(16); - image.setExplicitHeight(16); - return LytFlowInlineBlock.of(image); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - LytFlowSpan span = new LytFlowSpan(); - span.append( - LytFlowText.of( - icon.value() - .trim())); - return span; - } - private void compileParagraphBlock(MdAstParagraph astParagraph, LytBlockContainer parent) { var children = astParagraph.children(); if (children.size() == 1 && children.get(0) instanceof MdAstText soleText) { @@ -918,7 +742,7 @@ private LytBlock compileTable(GfmTable astTable, List widthHints) { } } - compileBlockContext(astCells.get(i), cell); + compileTableCellContent(astCells.get(i), cell); } rowIndex++; } @@ -927,7 +751,9 @@ private LytBlock compileTable(GfmTable astTable, List widthHints) { } public static LytBlock wrapFloatAwareIfNeeded(LytBlock block) { - if (block instanceof LytParagraph || block instanceof LytDocumentFloat || block instanceof LytFloatAwareBlock) { + if (block instanceof LytParagraph || block instanceof LytDocumentFloat + || block instanceof LytFloatAwareBlock + || block instanceof LytListItem) { return block; } return new LytFloatAwareBlock(block); @@ -936,7 +762,7 @@ public static LytBlock wrapFloatAwareIfNeeded(LytBlock block) { private @Nullable String getTableRowText(GfmTableRow row) { StringBuilder sb = new StringBuilder(); for (var cell : row.children()) { - if (!sb.isEmpty()) { + if (sb.length() > 0) { sb.append(' '); } sb.append(cell.toText()); @@ -956,7 +782,8 @@ public void compileFlowContext(Collection children, L } private void compileFlowContent(LytFlowParent layoutParent, MdAstAnyContent content) { - LytFlowContent layoutChild; + LytFlowContent layoutChild = null; + if (content instanceof MdAstText astText) { if (compileActionLinks(layoutParent, astText.value)) { layoutChild = null; @@ -969,81 +796,32 @@ private void compileFlowContent(LytFlowParent layoutParent, MdAstAnyContent cont text.setText(astText.value); layoutChild = text; } - } else if (content instanceof MdAstInlineCode astCode) { - var text = new LytFlowText(); - text.setText(astCode.value); - text.modifyStyle( - style -> style.italic(true) - .whiteSpace(WhiteSpaceMode.PRE)); - layoutChild = text; - } else if (content instanceof MdAstStrong astStrong) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.bold(true)); - compileFlowContext(astStrong, span); - layoutChild = span; - } else if (content instanceof MdAstEmphasis astEmphasis) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.italic(true)); - compileFlowContext(astEmphasis, span); - layoutChild = span; - } else if (content instanceof MdAstDelete astDelete) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.strikethrough(true)); - compileFlowContext(astDelete, span); - layoutChild = span; - } else if (content instanceof MdAstUnderline astUnderline) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.underlined(true)); - compileFlowContext(astUnderline, span); - layoutChild = span; - } else if (content instanceof MdAstWavyUnderline astWavy) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.wavyUnderline(true)); - compileFlowContext(astWavy, span); - layoutChild = span; - } else if (content instanceof MdAstDottedUnderline astDotted) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.dottedUnderline(true)); - compileFlowContext(astDotted, span); - layoutChild = span; - } else if (content instanceof MdAstMark astMark) { - var span = new LytFlowSpan(); - span.modifyStyle(style -> style.backgroundColor(new ConstantColor(DEFAULT_MARK_BACKGROUND_COLOR))); - compileFlowContext(astMark, span); - layoutChild = span; - } else if (content instanceof MdAstBreak) { - layoutChild = new LytFlowBreak(); - } else if (content instanceof MdAstLink astLink) { - layoutChild = compileLink(astLink, layoutParent); - } else if (content instanceof MdAstLinkReference astLinkReference) { - layoutChild = compileLinkReference(astLinkReference, layoutParent); - } else if (content instanceof MdAstImage astImage) { - var inlineBlock = new LytFlowInlineBlock(); - inlineBlock.setBlock(compileImage(astImage)); - layoutChild = inlineBlock; - } else if (content instanceof MdAstImageReference astImageReference) { - var inlineBlock = new LytFlowInlineBlock(); - inlineBlock.setBlock(compileImageReference(astImageReference)); - layoutChild = inlineBlock; - } else if (content instanceof MdAstHTML astHtml) { - layoutChild = compileHtmlInline(astHtml.value); } else if (content instanceof MdxJsxTextElement el) { if ("Spoiler".equals(el.name())) { var span = new LytSpoilerSpan(); span.modifyStyle(style -> style.backgroundColor(new ConstantColor(0xFF000000))); compileFlowContext(el, span); layoutChild = span; + } else if ("span".equals(el.name())) { + // Residual inline HTML span wrapper; preserve its children. + compileFlowContext(el, layoutParent); + layoutChild = null; } else { var compiler = tagCompilers.get(el.name()); if (compiler == null) { - layoutChild = createErrorFlowContent("Unhandled MDX element in flow context", content); + layoutChild = createErrorFlowContent( + "Unhandled MDX element in flow context: " + el.name(), + content); } else { layoutChild = null; compiler.compileFlowContext(this, layoutParent, el); } } } else { - layoutChild = createErrorFlowContent("Unhandled Markdown node in flow context", content); + layoutChild = createErrorFlowContent( + "Unhandled node in flow context: " + content.getClass() + .getSimpleName(), + content); } if (layoutChild != null) { @@ -1136,257 +914,6 @@ private boolean compileInlineDollarLatex(LytFlowParent layoutParent, String text return foundFormula; } - private LytFlowContent compileLink(MdAstLink astLink, LytErrorSink errorSink) { - var link = new LytFlowLink(); - var sound = GuideSoundParsers.parseActionUri(this, astLink.url); - if (sound != null) { - link.setClickSoundSpec(sound); - link.setClickCallback(uiHost -> {}); - compileFlowContext(astLink, link); - return link; - } - if (astLink.title != null && !astLink.title.isEmpty()) { - link.setTooltip(new TextTooltip(astLink.title)); - } - if (astLink.url != null && !astLink.url.isEmpty()) { - LinkParser.parseLink(this, astLink.url, new LinkParser.Visitor() { - - @Override - public void handlePage(ResourceLocation guideId, PageAnchor page) { - link.setGuideLink(guideId, page); - } - - @Override - public void handleExternal(URI uri) { - link.setExternalUrl(uri); - } - - @Override - public void handleError(String error) { - errorSink.appendError(PageCompiler.this, error, astLink); - } - }); - } - - compileFlowContext(astLink, link); - return link; - } - - private LytFlowContent compileLinkReference(MdAstLinkReference astLinkReference, LytErrorSink errorSink) { - MdAstDefinition definition = GuideMarkdownDefinitions.find(definitions, astLinkReference.identifier); - if (definition == null) { - return createErrorFlowContent("Missing link reference definition", astLinkReference); - } - - MdAstLink link = new MdAstLink(); - link.url = definition.url; - link.title = definition.title; - for (var child : astLinkReference.children()) { - if (child instanceof MdAstNode astChild) { - link.addChild(astChild); - } - } - return compileLink(link, errorSink); - } - - private LytImage compileImageReference(MdAstImageReference astImageReference) { - MdAstDefinition definition = GuideMarkdownDefinitions.find(definitions, astImageReference.identifier); - if (definition == null) { - LytImage image = new LytImage(); - image.setAlt(astImageReference.alt); - image.setTitle("Missing image reference: " + astImageReference.identifier); - return image; - } - - MdAstImage image = new MdAstImage(); - image.setAlt(astImageReference.alt); - image.setTitle(definition.title); - image.setUrl(definition.url); - return compileImage(image); - } - - private LytBlock compileCodeBlock(MdAstCode astCode) { - CodeBlockLanguage language = CodeBlockLanguageDetector.detect(astCode.lang, astCode.value); - if (shouldRenderCsvTable(astCode, language)) { - return compileCsvCodeBlock(astCode); - } - if (isFileTreeFence(astCode.lang)) { - return FileTreeCompiler.compile(this, astCode.value); - } - if (isFunctionGraphFence(astCode.lang)) { - return FunctionGraphFenceParser.parse(astCode.value); - } - if ("mermaid".equals(language.id())) { - LytMermaidMindmap mermaidBlock = tryCompileMermaidMindmap(astCode.value); - if (mermaidBlock != null) { - return mermaidBlock; - } - } - - LytCodeBlock codeBlock = new LytCodeBlock(); - codeBlock.setLanguageFenceName(astCode.lang != null ? astCode.lang : language.id()); - codeBlock.applyLanguage(language); - codeBlock.setCodeText(astCode.value); - Integer preferredWidth = parseCodeBlockWidth(astCode.meta); - if (preferredWidth != null) { - codeBlock.setPreferredBodyWidth(preferredWidth); - } - Integer forcedHeight = parseCodeBlockHeight(astCode.meta); - if (forcedHeight != null) { - codeBlock.setForcedBodyHeight(forcedHeight); - } - return codeBlock; - } - - private boolean shouldRenderCsvTable(MdAstCode astCode, CodeBlockLanguage language) { - return astCode.lang != null && "csv".equals(language.id()); - } - - private static boolean isFileTreeFence(@Nullable String fenceLanguage) { - if (fenceLanguage == null) { - return false; - } - String trimmed = fenceLanguage.trim(); - return "tree".equalsIgnoreCase(trimmed) || "filetree".equalsIgnoreCase(trimmed); - } - - private static boolean isFunctionGraphFence(@Nullable String fenceLanguage) { - if (fenceLanguage == null) { - return false; - } - String trimmed = fenceLanguage.trim(); - return "funcgraph".equalsIgnoreCase(trimmed) || "function".equalsIgnoreCase(trimmed) - || "functiongraph".equalsIgnoreCase(trimmed); - } - - private LytBlock compileCsvCodeBlock(MdAstCode astCode) { - String source = astCode.value; - List> rows = CsvTableParser.parse(source); - if (rows.isEmpty()) { - LytCodeBlock codeBlock = new LytCodeBlock(); - codeBlock.setLanguageFenceName("csv"); - codeBlock.applyLanguage(new CodeBlockLanguage("csv", "CSV")); - codeBlock.setCodeText(source); - return codeBlock; - } - - CsvFenceMeta meta = parseCsvFenceMeta(astCode.meta); - return CsvTableCompiler.buildTable(rows, meta.header(), meta.widthHints()); - } - - private CsvFenceMeta parseCsvFenceMeta(@Nullable String meta) { - if (meta == null || meta.trim() - .isEmpty()) { - return new CsvFenceMeta(true, List.of()); - } - - boolean header = true; - List widthHints = List.of(); - for (String token : splitMetaTokens(meta)) { - int equalsIndex = token.indexOf('='); - if (equalsIndex <= 0 || equalsIndex == token.length() - 1) { - continue; - } - - String key = token.substring(0, equalsIndex); - String value = stripOptionalQuotes(token.substring(equalsIndex + 1)); - if ("widths".equals(key)) { - widthHints = CsvTableCompiler.parseWidthHints(value); - } else if ("header".equals(key)) { - header = !"false".equalsIgnoreCase(value); - } - } - - return new CsvFenceMeta(header, widthHints); - } - - private MarkdownTableMeta extractMarkdownTableMeta(List children, int startIndex) { - if (startIndex >= children.size()) { - return extractMarkdownTableMetaFromSource(children, startIndex); - } - - StringBuilder metaExpression = new StringBuilder(); - int consumed = 0; - for (int index = startIndex; index < children.size(); index++) { - MdAstAnyContent child = children.get(index); - if (!(child instanceof MdAstParagraph paragraph)) { - break; - } - - String attributeText = getParagraphTextValue(paragraph); - if (attributeText == null) { - break; - } - - Matcher matcher = TABLE_ATTRIBUTE_LINE.matcher(attributeText.trim()); - if (!matcher.matches()) { - break; - } - - if (!metaExpression.isEmpty()) { - metaExpression.append(' '); - } - metaExpression.append(matcher.group(1)); - consumed++; - } - - if (consumed == 0) { - return extractMarkdownTableMetaFromSource(children, startIndex); - } - - List widthHints = parseWidthHintsFromMetaExpression(metaExpression.toString()); - if (widthHints.isEmpty()) { - return new MarkdownTableMeta(List.of(), consumed); - } - - return new MarkdownTableMeta(widthHints, consumed); - } - - private MarkdownTableMeta extractMarkdownTableMetaFromSource(List children, - int startIndex) { - if (startIndex <= 0 || startIndex > children.size()) { - return new MarkdownTableMeta(List.of(), 0); - } - - MdAstAnyContent tableChild = children.get(startIndex - 1); - if (!(tableChild instanceof MdAstNode tableNode) || tableNode.position() == null - || tableNode.position() - .end() == null) { - return new MarkdownTableMeta(List.of(), 0); - } - - int endLine = tableNode.position() - .end() - .line(); - String sourceText = getCurrentSourceText(); - if (endLine <= 0) { - return new MarkdownTableMeta(List.of(), 0); - } - - MarkdownTableMeta[] found = new MarkdownTableMeta[1]; - GuideStringLines.visitLines(sourceText, (line, lineIndex) -> { - if (lineIndex + 1 < endLine) { - return true; - } - String attributeLine = line.trim(); - if (attributeLine.isEmpty()) { - return true; - } - Matcher matcher = TABLE_ATTRIBUTE_LINE.matcher(attributeLine); - if (matcher.matches()) { - List widthHints = parseWidthHintsFromMetaExpression(matcher.group(1)); - found[0] = new MarkdownTableMeta(widthHints, 0); - } - return false; - }); - return found[0] != null ? found[0] : new MarkdownTableMeta(List.of(), 0); - } - - private @Nullable String getParagraphTextValue(MdAstParagraph paragraph) { - String text = paragraph.toText(); - return text.isEmpty() ? null : text; - } - private List parseWidthHintsFromMetaExpression(String metaExpression) { for (String token : splitMetaTokens(metaExpression)) { int equalsIndex = token.indexOf('='); @@ -1400,7 +927,7 @@ private List parseWidthHintsFromMetaExpression(String metaExpression) { return CsvTableCompiler.parseWidthHints(value); } } - return List.of(); + return Collections.emptyList(); } private List splitMetaTokens(String meta) { @@ -1422,7 +949,7 @@ private List splitMetaTokens(String meta) { continue; } if (Character.isWhitespace(ch) && !inQuotes) { - if (!current.isEmpty()) { + if (current.length() > 0) { tokens.add(current.toString()); current.setLength(0); } @@ -1430,7 +957,7 @@ private List splitMetaTokens(String meta) { } current.append(ch); } - if (!current.isEmpty()) { + if (current.length() > 0) { tokens.add(current.toString()); } return tokens; @@ -1447,50 +974,6 @@ private String stripOptionalQuotes(String value) { return value; } - private @Nullable Integer parseCodeBlockHeight(@Nullable String meta) { - if (meta == null || meta.trim() - .isEmpty()) { - return null; - } - Matcher matcher = CODEBLOCK_META_HEIGHT.matcher(meta); - if (!matcher.find()) { - return null; - } - String value = matcher.group(3) != null ? matcher.group(3) - : matcher.group(4) != null ? matcher.group(4) : matcher.group(5); - if (value == null || value.trim() - .isEmpty()) { - return null; - } - try { - return Math.max(0, Integer.parseInt(value.trim())); - } catch (NumberFormatException ignored) { - return null; - } - } - - private @Nullable Integer parseCodeBlockWidth(@Nullable String meta) { - if (meta == null || meta.trim() - .isEmpty()) { - return null; - } - Matcher matcher = CODEBLOCK_META_WIDTH.matcher(meta); - if (!matcher.find()) { - return null; - } - String value = matcher.group(3) != null ? matcher.group(3) - : matcher.group(4) != null ? matcher.group(4) : matcher.group(5); - if (value == null || value.trim() - .isEmpty()) { - return null; - } - try { - return Math.max(0, Integer.parseInt(value.trim())); - } catch (NumberFormatException ignored) { - return null; - } - } - private @Nullable BlockTagChildSource extractBlockTagChildrenSource(MdxJsxElementFields element) { String sourceText = getCurrentSourceText(); String body = MdxBlockTagSourceExtractor.extractRawBody(element, sourceText); @@ -1501,7 +984,7 @@ private String stripOptionalQuotes(String value) { return null; } - return new BlockTagChildSource(dedentBlockTagBody(body)); + return new BlockTagChildSource(DetailsContentExtractor.dedent(body)); } private BlockTagChildrenCacheEntry getBlockTagChildrenCacheEntry(MdxJsxElementFields element) { @@ -1522,170 +1005,6 @@ private BlockTagChildrenCacheEntry getBlockTagChildrenCacheEntry(MdxJsxElementFi return cachedEntry; } - private String dedentBlockTagBody(String body) { - String normalized = normalizeLineEndings(body); - if (normalized.isEmpty()) { - return normalized; - } - - List lines = GuideStringLines.splitLines(normalized); - int firstContentLine = 0; - while (firstContentLine < lines.size() && lines.get(firstContentLine) - .trim() - .isEmpty()) { - firstContentLine++; - } - - int minIndent = Integer.MAX_VALUE; - for (int i = firstContentLine; i < lines.size(); i++) { - String line = lines.get(i); - if (line.trim() - .isEmpty()) { - continue; - } - minIndent = Math.min(minIndent, leadingWhitespaceWidth(line)); - } - if (minIndent == Integer.MAX_VALUE) { - minIndent = 0; - } - - StringBuilder result = new StringBuilder(normalized.length()); - for (int i = firstContentLine; i < lines.size(); i++) { - if (i > firstContentLine) { - result.append('\n'); - } - result.append(removeLeadingWhitespace(lines.get(i), minIndent)); - } - - while (!result.isEmpty() && result.charAt(result.length() - 1) == '\n') { - result.setLength(result.length() - 1); - } - if (Objects.equals(body, normalized) && body.endsWith("\n")) { - result.append('\n'); - } - return result.toString(); - } - - private int leadingWhitespaceWidth(String line) { - int width = 0; - for (int i = 0; i < line.length(); i++) { - char ch = line.charAt(i); - if (ch == ' ') { - width++; - } else if (ch == '\t') { - width += 4; - } else { - break; - } - } - return width; - } - - private String removeLeadingWhitespace(String line, int widthToRemove) { - int index = 0; - int removed = 0; - while (index < line.length() && removed < widthToRemove) { - char ch = line.charAt(index); - if (ch == ' ') { - removed++; - index++; - } else if (ch == '\t') { - removed += 4; - index++; - } else { - break; - } - } - return line.substring(index); - } - - private @Nullable LytMermaidMindmap tryCompileMermaidMindmap(String source) { - try { - String normalized = MermaidMindmapParser.normalize(source); - LytMermaidMindmap block = new LytMermaidMindmap(MermaidMindmapParser.parse(normalized), normalized); - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [PageCompiler] Compiled fenced Mermaid runtime block for page {} ({} chars)", - pageId, - normalized.length()); - } - return block; - } catch (IllegalArgumentException e) { - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .warn( - "[GuideNH] [PageCompiler] Failed to compile fenced Mermaid runtime block for page {} from source: {}", - pageId, - source, - e); - } - return null; - } - } - - private void compileHtmlLiteral(LytParagraph paragraph, String html) { - String stripped = stripHtmlTags(html); - if (stripped.isEmpty()) { - paragraph.appendText(html); - } else { - paragraph.appendText(stripped); - } - paragraph.setMarginTop(DEFAULT_ELEMENT_SPACING); - paragraph.setMarginBottom(DEFAULT_ELEMENT_SPACING); - } - - private LytFlowContent compileHtmlInline(String html) { - LytFlowText text = new LytFlowText(); - text.setText(stripHtmlTags(html)); - return text; - } - - private String stripHtmlTags(String html) { - if (html == null || html.isEmpty()) { - return ""; - } - - StringBuilder stripped = new StringBuilder(html.length()); - boolean inTag = false; - for (int i = 0; i < html.length(); i++) { - char current = html.charAt(i); - if (current == '<') { - inTag = true; - continue; - } - if (current == '>') { - inTag = false; - continue; - } - if (!inTag) { - stripped.append(current); - } - } - return stripped.toString(); - } - - private LytImage compileImage(MdAstImage astImage) { - var image = new LytImage(); - image.setTitle(astImage.title); - image.setAlt(astImage.alt); - try { - var imageId = IdUtils.resolveLink(astImage.url, pageId); - var imageContent = pages.loadAsset(imageId); - if (imageContent == null) { - FMLLog.getLogger() - .error("[GuideNH] [PageCompiler] Couldn't find image {}", astImage.url); - image.setTitle("Missing image: " + astImage.url); - } - image.setImage(imageId, imageContent); - } catch (IllegalArgumentException e) { - FMLLog.getLogger() - .error("[GuideNH] [PageCompiler] Invalid image id: {}", astImage.url); - image.setTitle("Invalid image URL: " + astImage.url); - } - return image; - } - public LytBlock createErrorBlock(String text, UnistNode child) { var paragraph = new LytParagraph(); paragraph.append(createErrorFlowContent(text, child)); @@ -1722,15 +1041,9 @@ public LytFlowContent createErrorFlowContent(String text, UnistNode child) { span.appendText(tildes + "^"); span.appendBreak(); - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .warn("[GuideNH] [PageCompiler] {}\n{}\n{}\n", text, line, tildes + "^"); - } + GuideDebugLog.warnAlways("[GuideNH] [PageCompiler] {}\n{}\n{}\n", text, line, tildes + "^"); } else { - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .warn("[GuideNH] [PageCompiler] {}\n", text); - } + GuideDebugLog.warnAlways("[GuideNH] [PageCompiler] {}\n", text); } return span; @@ -1755,6 +1068,10 @@ public String getLanguage() { return language; } + public String getSourcePack() { + return sourcePack; + } + public PageCollection getPageCollection() { return pages; } @@ -1775,19 +1092,10 @@ private static boolean defaultPageExistsForLink(PageCompiler compiler, ResourceL ResourceLocation pageId) { PageCollection pages = compiler.getPageCollection(); if (guideId.equals(pages.getId())) { - return pages.pageExists(pageId) || syntheticPageExists(pages, pageId); + return pages.pageExists(pageId); } var guide = GuideRegistry.getById(guideId); - return guide != null && (guide.pageExists(pageId) || syntheticPageExists(guide, pageId)); - } - - private static boolean syntheticPageExists(Object pageContainer, ResourceLocation pageId) { - if (!MediaWikiPageIds.isSyntheticPage(pageId) - || !(pageContainer instanceof MediaWikiListContextProvider provider)) { - return false; - } - MediaWikiListContext context = provider.getMediaWikiListContext(); - return context != null && context.getParsedPage(pageId) != null; + return guide != null && guide.pageExists(pageId); } public interface PageLinkResolver { @@ -1819,7 +1127,7 @@ public void clearCompilerState(State state) { public String getCurrentSourceText() { List sourceSlices = getCompilerState(SOURCE_SLICE_STACK); if (!sourceSlices.isEmpty()) { - return sourceSlices.getLast() + return sourceSlices.get(sourceSlices.size() - 1) .source(); } return pageContent; @@ -1889,12 +1197,6 @@ private static Class castClass(Class rawClass) { return (Class) rawClass; } - @Desugar - private record CsvFenceMeta(boolean header, List widthHints) {} - - @Desugar - private record MarkdownTableMeta(List widthHints, int consumeChildCount) {} - @Desugar private record BlockTagChildSource(String source) {} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java index 8870d517..31268535 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/ParsedGuidePage.java @@ -14,7 +14,7 @@ public class ParsedGuidePage { private final String sourcePack; private final ResourceLocation id; private final String source; - private final MdAstRoot astRoot; + private volatile MdAstRoot astRoot; private final Frontmatter frontmatter; private final String language; private final @Nullable String parseFailureMessage; @@ -64,7 +64,19 @@ public Frontmatter getFrontmatter() { } public MdAstRoot getAstRoot() { - return astRoot; + MdAstRoot r = astRoot; + if (r != null) { + return r; + } + synchronized (this) { + r = astRoot; + if (r != null) { + return r; + } + ParsedGuidePage full = PageCompiler.parse(sourcePack, language, id, source); + astRoot = full.astRoot; + return astRoot; + } } public String getLanguage() { diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockImageCompiler.java index de00e4ac..8aefabb7 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockImageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockImageCompiler.java @@ -3,22 +3,11 @@ import java.util.Collections; import java.util.Set; -import net.minecraft.nbt.NBTTagCompound; -import net.minecraftforge.oredict.OreDictionary; - import org.jetbrains.annotations.Nullable; -import com.hfstudio.guidenh.guide.compiler.GuideItemReferenceResolver.ResolvedBlockReference; -import com.hfstudio.guidenh.guide.compiler.IdUtils; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; -import com.hfstudio.guidenh.guide.internal.structure.GuideTextNbtCodec; -import com.hfstudio.guidenh.guide.scene.CameraSettings; -import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; -import com.hfstudio.guidenh.guide.scene.PerspectivePreset; -import com.hfstudio.guidenh.guide.scene.element.BlockElementCompiler; -import com.hfstudio.guidenh.guide.scene.level.GuidebookPreviewBlockPlacer; -import com.hfstudio.guidenh.guide.scene.ponder.PonderNbtPath; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class BlockImageCompiler extends BlockTagCompiler { @@ -30,184 +19,69 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - var blockReference = MdxAttrs.getRequiredBlockReference(compiler, parent, el, "id"); - if (blockReference == null) { + String id = MdxAttrs.getString(compiler, parent, el, "id", null); + String ore = MdxAttrs.getString(compiler, parent, el, "ore", null); + + if ((id == null || id.trim() + .isEmpty()) && (ore == null + || ore.trim() + .isEmpty())) { + parent.appendError(compiler, "Missing id attribute (or ore).", el); return; } - var scene = new LytGuidebookScene(); - scene.setInteractive(false); - scene.setSceneButtonsVisible(false); - scene.setBottomControlsVisible(false); - scene.setReserveBottomControlArea(false); - scene.setVisibleLayerSliderEnabled(false); - scene.setGridButtonEnabled(false); - scene.setGridVisible(false); - scene.setAnnotationsVisible(false); - scene.setShowBackground(false); - - CameraSettings camera = scene.getCamera(); - camera.setZoom(clampZoom(MdxAttrs.getFloat(compiler, parent, el, "scale", 1f))); - camera.setPerspectivePreset(resolvePerspective(compiler, parent, el)); - - int meta = resolveBlockMeta(blockReference); - NBTTagCompound tileTag = resolveTileTag(compiler, parent, el); - GuidebookPreviewBlockPlacer.place( - scene.getLevel(), - 0, - 0, - 0, - blockReference.block(), + int meta = MdxAttrs.getInt(compiler, parent, el, "meta", Integer.MIN_VALUE); + String nbt = MdxAttrs.getString(compiler, parent, el, "nbt", null); + float scale = MdxAttrs.getFloat(compiler, parent, el, "scale", 1f); + String perspective = MdxAttrs.getString(compiler, parent, el, "perspective", null); + int width = MdxAttrs.getInt(compiler, parent, el, "width", 128); + int height = MdxAttrs.getInt(compiler, parent, el, "height", 128); + + // Create placeholder block that carries all extracted config to BlockImageScript + BlockImagePlaceholder placeholder = new BlockImagePlaceholder( + id, + ore, meta, - tileTag, - blockReference.registryId() - .toString()); - - finalizeSceneLayout(scene); - scene.snapshotInitialCamera(); - scene.captureInitialInteractiveState(); - parent.append(scene); - } - - private PerspectivePreset resolvePerspective(PageCompiler compiler, LytBlockContainer parent, - MdxJsxElementFields el) { - String rawPerspective = MdxAttrs.getString(compiler, parent, el, "perspective", null); - if (rawPerspective == null || rawPerspective.trim() - .isEmpty()) { - return PerspectivePreset.ISOMETRIC_NORTH_EAST; - } - return PerspectivePreset.fromSerializedName(rawPerspective.trim()); - } - - private int resolveBlockMeta(ResolvedBlockReference blockReference) { - if (blockReference.hasExplicitMeta() && blockReference.explicitMeta() != OreDictionary.WILDCARD_VALUE) { - return blockReference.explicitMeta(); - } - return BlockElementCompiler.defaultMetaFor(blockReference.block(), null); + nbt, + scale, + perspective, + width, + height); + placeholder.setStyleClass("BlockImage"); + placeholder.setStyle(LytParagraph.PLACEHOLDER_STYLE); + placeholder.appendText("[BlockImage]"); + parent.append(placeholder); } - @Nullable - private NBTTagCompound resolveTileTag(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - NBTTagCompound merged = resolveInlineIdNbt(compiler, parent, el); - String nbtText = MdxAttrs.getString(compiler, parent, el, "nbt", null); - if (nbtText == null || nbtText.trim() - .isEmpty()) { - return merged; - } - - try { - NBTTagCompound attrTag = GuideTextNbtCodec.readTextSafeCompound(nbtText.trim()); - if (merged == null) { - return attrTag; - } - PonderNbtPath.mergeCompound(merged, attrTag); - return merged; - } catch (Exception e) { - parent.appendError(compiler, "Bad nbt: " + e.getMessage(), el); - return merged; + /** + * Placeholder block that stores all extracted block-image configuration for deferred scene + * creation by {@code BlockImageScript}. + */ + public static class BlockImagePlaceholder extends LytParagraph { + + @Nullable + public final String id; + @Nullable + public final String ore; + public final int meta; + @Nullable + public final String nbt; + public final float scale; + @Nullable + public final String perspective; + public final int width; + public final int height; + + public BlockImagePlaceholder(@Nullable String id, @Nullable String ore, int meta, @Nullable String nbt, + float scale, @Nullable String perspective, int width, int height) { + this.id = id; + this.ore = ore; + this.meta = meta; + this.nbt = nbt; + this.scale = scale; + this.perspective = perspective; + this.width = width; + this.height = height; } } - - @Nullable - private NBTTagCompound resolveInlineIdNbt(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - String oreName = MdxAttrs.getString(el, "ore", null); - if (oreName != null && !oreName.trim() - .isEmpty()) { - return null; - } - String rawId = MdxAttrs.getString(el, "id", null); - if (rawId == null || rawId.trim() - .isEmpty()) { - return null; - } - String trimmedId = rawId.trim(); - if (trimmedId.indexOf('{') < 0) { - return null; - } - - try { - IdUtils.ParsedItemRef parsed = IdUtils.parseItemRef( - trimmedId, - compiler.getPageId() - .getResourceDomain()); - return parsed == null || parsed.nbt() == null ? null - : (NBTTagCompound) parsed.nbt() - .copy(); - } catch (IllegalArgumentException e) { - parent.appendError(compiler, "Malformed id " + trimmedId + ": " + e.getMessage(), el); - return null; - } - } - - private void finalizeSceneLayout(LytGuidebookScene scene) { - if (scene.getLevel() - .isEmpty()) { - return; - } - - int width = scene.getSceneWidth(); - int height = scene.getSceneHeight(); - CameraSettings camera = scene.getCamera(); - camera.setViewportSize(width, height); - - float[] center = scene.getLevel() - .getCenter(); - camera.setRotationCenter(center[0], center[1], center[2]); - - int[] bounds = scene.getLevel() - .getBounds(); - float minX = bounds[0]; - float minY = bounds[1]; - float minZ = bounds[2]; - float maxX = bounds[3] + 1f; - float maxY = bounds[4] + 1f; - float maxZ = bounds[5] + 1f; - - float savedOffsetX = camera.getOffsetX(); - float savedOffsetY = camera.getOffsetY(); - camera.setOffsetX(0f); - camera.setOffsetY(0f); - - float minScreenX = Float.MAX_VALUE; - float maxScreenX = -Float.MAX_VALUE; - float minScreenY = Float.MAX_VALUE; - float maxScreenY = -Float.MAX_VALUE; - for (int cornerIndex = 0; cornerIndex < 8; cornerIndex++) { - float worldX = (cornerIndex & 1) == 0 ? minX : maxX; - float worldY = (cornerIndex & 2) == 0 ? minY : maxY; - float worldZ = (cornerIndex & 4) == 0 ? minZ : maxZ; - var screenPoint = camera.worldToScreen(worldX, worldY, worldZ); - if (screenPoint.x < minScreenX) { - minScreenX = screenPoint.x; - } - if (screenPoint.x > maxScreenX) { - maxScreenX = screenPoint.x; - } - if (screenPoint.y < minScreenY) { - minScreenY = screenPoint.y; - } - if (screenPoint.y > maxScreenY) { - maxScreenY = screenPoint.y; - } - } - - int autoWidth = clampSceneDimension((int) Math.ceil(maxScreenX - minScreenX) + 16); - int autoHeight = clampSceneDimension((int) Math.ceil(maxScreenY - minScreenY) + 16); - scene.setSceneSize(autoWidth, autoHeight); - camera.setViewportSize(autoWidth, autoHeight); - - camera.setOffsetX(0f); - camera.setOffsetY(0f); - var projectedCenter = camera.worldToScreen(center[0], center[1], center[2]); - camera.setOffsetX(-projectedCenter.x + savedOffsetX); - camera.setOffsetY(projectedCenter.y + savedOffsetY); - } - - private int clampSceneDimension(int dimension) { - return Math.clamp(dimension, 64, 256); - } - - private float clampZoom(float zoom) { - return Math.clamp(zoom, LytGuidebookScene.MIN_ZOOM, LytGuidebookScene.MAX_ZOOM); - } } 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 new file mode 100644 index 00000000..c20eb27c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/BlockquoteCompiler.java @@ -0,0 +1,130 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.color.SymbolicColor; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytAlertBox; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytNode; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.block.LytQuoteBox; +import com.hfstudio.guidenh.guide.document.block.LytVBox; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.BlockquoteDirective; +import com.hfstudio.guidenh.guide.style.BorderStyle; +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.MdAstText; + +public class BlockquoteCompiler extends BlockTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("blockquote"); + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + BlockquoteDirective directive = MarkdownRuntimeBlocks.parseBlockquoteDirective(el); + if (directive != null && directive.alertType() != null) { + LytAlertBox alertBox = new LytAlertBox(); + alertBox.setTitle( + directive.alertType() + .displayText(), + directive.alertType()); + alertBox.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + alertBox.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + compileDirectiveBody(compiler, directive, alertBox); + normalizeBlockMargins(alertBox); + parent.append(PageCompiler.wrapFloatAwareIfNeeded(alertBox)); + return; + } + + if (directive != null && (directive.title() != null || directive.icon() != null)) { + LytQuoteBox quoteBox = new LytQuoteBox(); + quoteBox.setQuoteStyle( + directive.accentColor(), + directive.title(), + CalloutIconSupport.buildFlowIcon(compiler, directive.icon())); + quoteBox.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + quoteBox.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + compileDirectiveBody(compiler, directive, quoteBox); + normalizeBlockMargins(quoteBox); + shiftFirstParagraphDown(quoteBox, 1); + parent.append(PageCompiler.wrapFloatAwareIfNeeded(quoteBox)); + return; + } + + // Plain blockquote + LytVBox blockquote = new LytVBox(); + blockquote.setBackgroundColor(SymbolicColor.BLOCKQUOTE_BACKGROUND); + blockquote.setPadding(5); + blockquote.setPaddingLeft(10); + blockquote.setBorderLeft(new BorderStyle(SymbolicColor.TABLE_BORDER, 2)); + blockquote.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + blockquote.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + compiler.compileBlockContext(el.children(), blockquote); + normalizeBlockMargins(blockquote); + shiftFirstParagraphDown(blockquote, 1); + parent.append(PageCompiler.wrapFloatAwareIfNeeded(blockquote)); + } + + private void compileDirectiveBody(PageCompiler compiler, BlockquoteDirective directive, LytBlockContainer parent) { + // 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 + && directive.children() + .get(0) == directive.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); + for (int i = 1; i < directive.children() + .size(); i++) { + compiler.compileBlockContext( + Collections.singletonList( + directive.children() + .get(i)), + parent); + } + } else { + compiler.compileBlockContext(directive.children(), parent); + } + } + + private void normalizeBlockMargins(LytNode box) { + var boxChildren = box.getChildren(); + if (!boxChildren.isEmpty()) { + if (boxChildren.get(0) instanceof LytParagraph) { + ((LytParagraph) boxChildren.get(0)).setMarginTop(0); + } + if (boxChildren.get(boxChildren.size() - 1) instanceof LytParagraph) { + ((LytParagraph) boxChildren.get(boxChildren.size() - 1)).setMarginBottom(0); + } + } + } + + private void shiftFirstParagraphDown(LytNode box, int pixels) { + var boxChildren = box.getChildren(); + if (!boxChildren.isEmpty() && boxChildren.get(0) instanceof LytParagraph) { + LytParagraph first = (LytParagraph) boxChildren.get(0); + first.setPaddingTop(first.getPaddingTop() + pixels); + } + } + + private static void stripLeadingText(MdxJsxFlowElement paragraph, String replacementText) { + for (Object child : paragraph.children()) { + if (child instanceof MdAstText text && !text.value.trim() + .isEmpty()) { + text.setValue(replacementText); + return; + } + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CalloutIconSupport.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CalloutIconSupport.java new file mode 100644 index 00000000..cd116f74 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CalloutIconSupport.java @@ -0,0 +1,66 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import net.minecraft.item.ItemStack; +import net.minecraft.util.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.compiler.IdUtils; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytImage; +import com.hfstudio.guidenh.guide.document.block.LytItemImage; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.QuoteIconSpec; + +public class CalloutIconSupport { + + private static final int INLINE_PNG_SIZE = 8; + private static final float INLINE_ITEM_SCALE = 0.75f; + + private CalloutIconSupport() {} + + public static @Nullable LytFlowContent buildFlowIcon(PageCompiler compiler, @Nullable QuoteIconSpec icon) { + if (icon == null || icon.value() == null + || icon.value() + .trim() + .isEmpty()) { + return null; + } + String value = icon.value() + .trim(); + return switch (icon.kind()) { + case TEXT -> LytFlowText.of(value); + case PNG -> buildPngIcon(compiler, value); + case ITEM -> buildItemIcon(compiler, value); + }; + } + + private static LytFlowContent buildPngIcon(PageCompiler compiler, String value) { + LytImage image = new LytImage(); + image.setExplicitWidth(INLINE_PNG_SIZE); + image.setExplicitHeight(INLINE_PNG_SIZE); + try { + ResourceLocation imageId = IdUtils.resolveLink(value, compiler.getPageId()); + image.setImage(imageId, compiler.loadAsset(imageId)); + } catch (IllegalArgumentException ignored) { + image.setTitle("Invalid image: " + value); + } + return LytFlowInlineBlock.of(image); + } + + private static LytFlowContent buildItemIcon(PageCompiler compiler, String value) { + ItemStack stack = IdUtils.resolveItemStack( + value, + compiler.getPageId() + .getResourceDomain()); + if (stack == null) { + return LytFlowText.of(value); + } + LytItemImage image = new LytItemImage(stack); + image.setInline(true); + image.setScale(INLINE_ITEM_SCALE); + return LytFlowInlineBlock.of(image); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java new file mode 100644 index 00000000..2b6abfa5 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CodeCompiler.java @@ -0,0 +1,37 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.model.MdAstText; + +public class CodeCompiler extends FlowTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("code"); + } + + @Override + protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + var text = new LytFlowText(); + // Extract text from child — has one MdAstText child from converter + String value = ""; + if (!el.children() + .isEmpty() + && el.children() + .get(0) instanceof MdAstText t) { + value = t.value; + } + text.setText(value); + text.modifyStyle( + style -> style.inlineCode(true) + .whiteSpace(WhiteSpaceMode.PRE_WRAP)); + parent.append(text); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java index 9283e94e..7b1ea5f1 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CommandLinkCompiler.java @@ -3,11 +3,8 @@ import java.util.Collections; import java.util.Set; -import net.minecraft.client.Minecraft; - import org.jetbrains.annotations.Nullable; -import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.compiler.IndexingContext; import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; @@ -16,8 +13,6 @@ import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; -import cpw.mods.fml.common.FMLLog; - public class CommandLinkCompiler extends FlowTagCompiler { @Override @@ -35,21 +30,16 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen parent.appendError(compiler, "command must start with /", el); return; } + var closeGuide = MdxAttrs.getBoolean(compiler, parent, el, "close", false); var title = el.getAttributeString("title", ""); var link = new LytFlowLink(); link.setTooltip(buildTooltip(title, command)); - var pageId = compiler.getPageId(); - link.setClickCallback(uiHost -> { - var mc = Minecraft.getMinecraft(); - if (mc.thePlayer != null) { - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info("[GuideNH] [CommandLinkCompiler] Sending command from page {}: {}", pageId, command); - } - mc.thePlayer.sendChatMessage(command); - } - }); + // Pure placeholder: mark for the script to materialize at mount time + link.setStyleClass("CommandLink"); + link.setData("command", command); + link.setData("close", closeGuide); + link.setData("title", title); compiler.compileFlowContext(el.children(), link); parent.append(link); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ContentTabsSpec.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ContentTabsSpec.java new file mode 100644 index 00000000..b48fd351 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ContentTabsSpec.java @@ -0,0 +1,27 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import com.github.bsideup.jabel.Desugar; +import com.hfstudio.guidenh.guide.color.ColorValue; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks.QuoteIconSpec; +import com.hfstudio.guidenh.libs.unist.UnistNode; + +@Desugar +public record ContentTabsSpec(@Nullable String title, @Nullable QuoteIconSpec icon, @Nullable String defaultTitle, + @Nullable Integer defaultIndex, @Nullable ColorValue accentColor, List tabs, + List issues) { + + public boolean hasRenderableTabs() { + return !tabs.isEmpty(); + } + + @Desugar + public record TabEntry(String title, LytBlock body, UnistNode sourceNode) {} + + @Desugar + public record ValidationIssue(String message, UnistNode sourceNode) {} +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ContentTabsTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ContentTabsTagCompiler.java new file mode 100644 index 00000000..c814ebfa --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ContentTabsTagCompiler.java @@ -0,0 +1,160 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.color.ColorValue; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.LytErrorSink; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytContentTabsBlock; +import com.hfstudio.guidenh.guide.document.block.LytVBox; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownRuntimeBlocks; +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.unist.UnistNode; + +public class ContentTabsTagCompiler extends BlockTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("ContentTabs"); + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + ContentTabsSpec spec = parseSpec(compiler, el); + for (ContentTabsSpec.ValidationIssue issue : spec.issues()) { + parent.appendError(compiler, issue.message(), issue.sourceNode()); + } + if (!spec.hasRenderableTabs()) { + return; + } + parent.append( + new LytContentTabsBlock( + spec.title(), + CalloutIconSupport.buildFlowIcon(compiler, spec.icon()), + resolveInitialIndex(spec), + spec.accentColor(), + spec.tabs())); + } + + private ContentTabsSpec parseSpec(PageCompiler compiler, MdxJsxElementFields el) { + List children = resolveChildren(compiler, el); + List tabs = new ArrayList<>(); + List issues = new ArrayList<>(); + String title = el.getAttributeString("title", null); + var icon = MarkdownRuntimeBlocks.parseQuoteIconAttributes(el); + Integer defaultIndex = parseDefaultIndex(el, issues, el); + String defaultTitle = el.getAttributeString("default", null); + ColorValue accentColor = MdxAttrs.getColor(compiler, new ValidationIssueSink(issues), el, "color", null); + collectTabs(compiler, children, tabs, issues); + validateDefaultTarget(defaultTitle, defaultIndex, tabs, issues, el); + return new ContentTabsSpec(title, icon, defaultTitle, defaultIndex, accentColor, tabs, issues); + } + + private List resolveChildren(PageCompiler compiler, MdxJsxElementFields el) { + String source = compiler.getBlockTagChildrenSource(el); + return source != null ? compiler.reparseBlockTagChildren(el) : el.children(); + } + + private void collectTabs(PageCompiler compiler, List children, + List tabs, List issues) { + for (MdAstAnyContent child : children) { + if (!(child instanceof MdxJsxFlowElement element)) { + issues.add(new ContentTabsSpec.ValidationIssue("ContentTabs only accepts children.", child)); + continue; + } + if (!"Tab".equals(element.name())) { + issues.add(new ContentTabsSpec.ValidationIssue("ContentTabs only accepts children.", element)); + continue; + } + String title = element.getAttributeString("title", null); + if (title == null || title.trim() + .isEmpty()) { + issues.add(new ContentTabsSpec.ValidationIssue(" requires a non-empty title attribute.", element)); + continue; + } + LytVBox body = new LytVBox(); + body.setGap(4); + compiler.compileBlockContextInSourceContext(element.children(), body); + tabs.add(new ContentTabsSpec.TabEntry(title.trim(), body, element)); + } + } + + private @Nullable Integer parseDefaultIndex(MdxJsxElementFields el, List issues, + UnistNode sourceNode) { + String raw = el.getAttributeString("defaultIndex", null); + if (raw == null || raw.trim() + .isEmpty()) { + return null; + } + try { + return Integer.parseInt(raw.trim()); + } catch (NumberFormatException ignored) { + issues.add(new ContentTabsSpec.ValidationIssue("defaultIndex must be an integer.", sourceNode)); + return null; + } + } + + private void validateDefaultTarget(@Nullable String defaultTitle, @Nullable Integer defaultIndex, + List tabs, List issues, UnistNode sourceNode) { + if (defaultIndex != null && (defaultIndex < 0 || defaultIndex >= tabs.size())) { + issues + .add(new ContentTabsSpec.ValidationIssue("defaultIndex is out of range for ContentTabs.", sourceNode)); + return; + } + if (defaultIndex == null && defaultTitle != null) { + boolean matched = tabs.stream() + .anyMatch(tab -> defaultTitle.equals(tab.title())); + if (!matched) { + issues.add(new ContentTabsSpec.ValidationIssue("default does not match any title.", sourceNode)); + } + } + } + + static int resolveInitialIndex(ContentTabsSpec spec) { + if (spec.tabs() + .isEmpty()) { + return 0; + } + if (spec.defaultIndex() != null && spec.defaultIndex() >= 0 + && spec.defaultIndex() < spec.tabs() + .size()) { + return spec.defaultIndex(); + } + if (spec.defaultTitle() != null) { + for (int index = 0; index < spec.tabs() + .size(); index++) { + if (spec.defaultTitle() + .equals( + spec.tabs() + .get(index) + .title())) { + return index; + } + } + } + return 0; + } + + private static class ValidationIssueSink implements LytErrorSink { + + private final List issues; + + private ValidationIssueSink(List issues) { + this.issues = issues; + } + + @Override + public void appendError(PageCompiler compiler, String text, UnistNode node) { + issues.add(new ContentTabsSpec.ValidationIssue(text, node)); + } + + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java index 49984d71..6ffdf903 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/CsvTableCompiler.java @@ -43,60 +43,62 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl return; } - byte[] data = compiler.loadAsset(csvId); - if (data == null) { - parent.appendError(compiler, "Missing CSV asset: " + csvId, el); - return; - } - - List> rows = CsvTableParser.parse(new String(data, StandardCharsets.UTF_8)); - if (rows.isEmpty()) { - parent.appendError(compiler, "CsvTable source is empty: " + csvId, el); - return; - } - boolean header = MdxAttrs.getBoolean(compiler, parent, el, "header", true); - parent.append( - buildTable(rows, header, parseWidthHints(MdxAttrs.getString(compiler, parent, el, "widths", null)))); + List widths = parseWidthHints(MdxAttrs.getString(compiler, parent, el, "widths", null)); + + CsvTablePlaceholder placeholder = new CsvTablePlaceholder( + csvId.toString(), + header, + widths, + compiler.getSourcePack(), + compiler.getLanguage(), + compiler.getPageId() + .toString()); + placeholder.appendText("[CsvTable]"); + parent.append(placeholder); } @Override public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) { + // Load CSV asset content and index cell values for search. + // CSV data is available at compile time (src is a file path), unlike Category/Special + // whose data is resolved at MOUNT time by scripts. String src; try { src = MdxAttrs.getString(el, "src", null); } catch (MdxAttrs.AttributeException e) { return; } - if (src == null || src.trim() + if (src != null && !src.trim() .isEmpty()) { - return; - } - - ResourceLocation csvId; - try { - csvId = IdUtils.resolveLink(src.trim(), indexer.getPageId()); - } catch (IllegalArgumentException e) { - return; - } - - byte[] data = indexer.loadAsset(csvId); - if (data == null) { - return; - } - - List> rows = CsvTableParser.parse(new String(data, StandardCharsets.UTF_8)); - for (List row : rows) { - for (String cell : row) { - if (!cell.isEmpty()) { - sink.appendText(el, cell); + try { + ResourceLocation csvId = IdUtils.resolveLink(src.trim(), indexer.getPageId()); + byte[] data = indexer.loadAsset(csvId); + if (data != null) { + List> rows = CsvTableParser.parse(new String(data, StandardCharsets.UTF_8)); + for (List row : rows) { + for (String cell : row) { + if (!cell.isEmpty()) { + sink.appendText(el, cell); + } + } + sink.appendBreak(); + } } + } catch (Exception ignored) { + // Fallback: index the src path string if asset loading fails + sink.appendText(el, src); + sink.appendBreak(); } - sink.appendBreak(); } } public static LytTable buildTable(List> rows, boolean header, List widthHints) { + return buildTable(null, rows, header, widthHints); + } + + public static LytTable buildTable(PageCompiler compiler, List> rows, boolean header, + List widthHints) { LytTable table = new LytTable(); table.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); table.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); @@ -113,7 +115,7 @@ public static LytTable buildTable(List> rows, boolean header, List< table.getOrCreateColumn(columnIndex) .setPreferredWidth(widthHints.get(columnIndex)); } - appendCellContent(row.appendCell(), values.get(columnIndex)); + appendCellContent(compiler, row.appendCell(), values.get(columnIndex)); } firstRow = false; rowIndex++; @@ -155,7 +157,7 @@ public static List parseWidthHints(String rawWidths) { return result; } - private static void appendCellContent(LytTableCell cell, String value) { + private static void appendCellContent(PageCompiler compiler, LytTableCell cell, String value) { LytParagraph paragraph = new LytParagraph(); paragraph.setMarginTop(0); paragraph.setMarginBottom(0); @@ -167,7 +169,12 @@ private static void appendCellContent(LytTableCell cell, String value) { if (end < 0) { end = value.length(); } - paragraph.appendText(value.substring(start, end)); + String line = value.substring(start, end); + if (compiler != null) { + compiler.compileInlineMarkdown(line, paragraph); + } else { + paragraph.appendText(line); + } if (end == value.length()) { break; } @@ -177,4 +184,26 @@ private static void appendCellContent(LytTableCell cell, String value) { cell.append(paragraph); } + + public static class CsvTablePlaceholder extends LytParagraph { + + public final String src; + public final boolean header; + public final List widths; + public final String sourcePack; + public final String language; + public final String pageId; + + public CsvTablePlaceholder(String src, boolean header, List widths, String sourcePack, String language, + String pageId) { + this.src = src; + this.header = header; + this.widths = widths; + this.sourcePack = sourcePack; + this.language = language; + this.pageId = pageId; + setStyleClass("CsvTable"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DelUWaveMarkCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DelUWaveMarkCompiler.java new file mode 100644 index 00000000..f0d7f46a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DelUWaveMarkCompiler.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.LinkedHashSet; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class DelUWaveMarkCompiler extends FlowTagCompiler { + + @Override + public Set getTagNames() { + var tags = new LinkedHashSet(); + tags.add("del"); + tags.add("u"); + tags.add("wavy"); + tags.add("dotted"); + return tags; + } + + @Override + protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + var span = new LytFlowSpan(); + String name = el.name(); + if (name != null) { + switch (name) { + case "del" -> span.modifyStyle(s -> s.strikethrough(true)); + case "u" -> span.modifyStyle(s -> s.underlined(true)); + case "wavy" -> span.modifyStyle(s -> s.wavyUnderline(true)); + case "dotted" -> span.modifyStyle(s -> s.dottedUnderline(true)); + default -> {} + } + } + compiler.compileFlowContext(el.children(), span); + parent.append(span); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsContentExtractor.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsContentExtractor.java new file mode 100644 index 00000000..3847a48c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsContentExtractor.java @@ -0,0 +1,104 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; + +public class DetailsContentExtractor { + + private static final Pattern SUMMARY_PATTERN = Pattern + .compile("(?is)^\\s*<\\s*summary\\b[^>]*>(.*?)\\s*(?:\\R)?"); + + private DetailsContentExtractor() {} + + public static DetailsContent extract(@Nullable String source) { + String body = source != null ? source : ""; + Matcher matcher = SUMMARY_PATTERN.matcher(body); + if (!matcher.find()) { + return new DetailsContent(null, body); + } + return new DetailsContent(matcher.group(1), body.substring(matcher.end())); + } + + public static String dedent(String body) { + String normalized = GuideStringLines.normalizeLineEndings(body != null ? body : ""); + if (normalized.isEmpty()) { + return normalized; + } + + var lines = GuideStringLines.splitLines(normalized); + int firstContentLine = 0; + while (firstContentLine < lines.size() && lines.get(firstContentLine) + .trim() + .isEmpty()) { + firstContentLine++; + } + + int minIndent = Integer.MAX_VALUE; + for (int i = firstContentLine; i < lines.size(); i++) { + String line = lines.get(i); + if (line.trim() + .isEmpty()) { + continue; + } + minIndent = Math.min(minIndent, leadingWhitespaceWidth(line)); + } + if (minIndent == Integer.MAX_VALUE) { + minIndent = 0; + } + + StringBuilder result = new StringBuilder(normalized.length()); + for (int i = firstContentLine; i < lines.size(); i++) { + if (i > firstContentLine) { + result.append('\n'); + } + result.append(removeLeadingWhitespace(lines.get(i), minIndent)); + } + + while (result.length() > 0 && result.charAt(result.length() - 1) == '\n') { + result.setLength(result.length() - 1); + } + if (body != null && body.equals(normalized) && body.endsWith("\n")) { + result.append('\n'); + } + return result.toString(); + } + + private static int leadingWhitespaceWidth(String line) { + int width = 0; + for (int i = 0; i < line.length(); i++) { + char ch = line.charAt(i); + if (ch == ' ') { + width++; + } else if (ch == '\t') { + width += 4; + } else { + break; + } + } + return width; + } + + private static String removeLeadingWhitespace(String line, int widthToRemove) { + int index = 0; + int removed = 0; + while (index < line.length() && removed < widthToRemove) { + char ch = line.charAt(index); + if (ch == ' ') { + removed++; + index++; + } else if (ch == '\t') { + removed += 4; + index++; + } else { + break; + } + } + return line.substring(index); + } + + public record DetailsContent(@Nullable String summaryMarkdown, String bodyMarkdown) {} +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsTagCompiler.java index bbb35e77..cb2ce387 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/DetailsTagCompiler.java @@ -5,11 +5,10 @@ import java.util.Set; import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.tags.DetailsContentExtractor.DetailsContent; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; import com.hfstudio.guidenh.guide.document.block.LytDetailsBlock; -import com.hfstudio.guidenh.guide.document.block.LytSizeBox; 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; public class DetailsTagCompiler extends BlockTagCompiler { @@ -27,12 +26,39 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl details.setOpen(el.hasAttribute("open")); details.setFallbackSummaryText("Details"); - String detailsBodySource = compiler.getBlockTagChildrenSource(el); - List children = detailsBodySource != null ? compiler.reparseBlockTagChildren(el) - : el.children(); + String childrenSource = compiler.getBlockTagChildrenSource(el); + if (childrenSource != null) { + DetailsContent extracted = DetailsContentExtractor.extract(childrenSource); + if (extracted.summaryMarkdown() != null) { + details.getSummaryBox() + .clearContent(); + compiler.compileInlineMarkdown(extracted.summaryMarkdown(), details.getSummaryBox()); + if (details.getSummaryBox() + .isEmpty()) { + details.setFallbackSummaryText("Details"); + } + } + compiler.compileBlockMarkdown(extracted.bodyMarkdown(), details.getContentBox()); + } else { + compileAstChildren(compiler, details, el.children()); + } + + Integer width = readOptionalInt(el, "width"); + Integer height = readOptionalInt(el, "height"); + if (width != null) { + details.setPreferredWidth(width); + } + if (height != null) { + details.setPreferredContentHeight(height); + } + parent.append(details); + } + + private void compileAstChildren(PageCompiler compiler, LytDetailsBlock details, + List children) { int bodyStart = 0; - if (!children.isEmpty() && children.getFirst() instanceof MdxJsxFlowElement summaryElement - && "summary".equals(summaryElement.name())) { + MdxJsxElementFields summaryElement = findLeadingSummary(children); + if (summaryElement != null) { details.getSummaryBox() .clearContent(); compiler.compileInlineFragment(summaryElement.children(), details.getSummaryBox()); @@ -45,30 +71,19 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl if (bodyStart < children.size()) { List bodyChildren = children.subList(bodyStart, children.size()); - if (detailsBodySource != null) { - compiler.withSourceContext( - detailsBodySource, - () -> compiler.compileBlockContextInSourceContext(bodyChildren, details.getContentBox())); - } else { - compiler.compileBlockContextInSourceContext(bodyChildren, details.getContentBox()); - } + compiler.compileBlockContextInSourceContext(bodyChildren, details.getContentBox()); } + } - Integer width = readOptionalInt(el, "width"); - Integer height = readOptionalInt(el, "height"); - if (width != null || height != null) { - LytSizeBox sizeBox = new LytSizeBox(); - if (width != null) { - sizeBox.setPreferredWidth(width); - } - if (height != null) { - sizeBox.setPreferredHeight(height); - } - sizeBox.append(details); - parent.append(sizeBox); - return; + private MdxJsxElementFields findLeadingSummary(List children) { + if (children.isEmpty()) { + return null; } - parent.append(details); + if (children.getFirst() instanceof MdxJsxElementFields summaryElement + && "summary".equals(summaryElement.name())) { + return summaryElement; + } + return null; } private Integer readOptionalInt(MdxJsxElementFields el, String name) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/EmphasisCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/EmphasisCompiler.java new file mode 100644 index 00000000..3bf576be --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/EmphasisCompiler.java @@ -0,0 +1,25 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class EmphasisCompiler extends FlowTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("em"); + } + + @Override + protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + var span = new LytFlowSpan(); + span.modifyStyle(style -> style.italic(true)); + compiler.compileFlowContext(el.children(), span); + parent.append(span); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FileTreeTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FileTreeTagCompiler.java index b09bca91..99501b2e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FileTreeTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FileTreeTagCompiler.java @@ -2,6 +2,7 @@ import java.util.Collections; import java.util.Set; +import java.util.function.IntConsumer; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; @@ -44,8 +45,7 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl parent.append(tree); } - private static void applyOptionalIntAttribute(MdxJsxElementFields el, String name, - java.util.function.IntConsumer setter) { + private static void applyOptionalIntAttribute(MdxJsxElementFields el, String name, IntConsumer setter) { String raw = el.getAttributeString(name, null); if (raw == null || raw.isEmpty()) { return; diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java index 3e48fab7..94281d4f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/FloatingImageCompiler.java @@ -13,23 +13,23 @@ import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.ImageRegionAnnotation; -import com.hfstudio.guidenh.guide.document.block.LytImage; +import com.hfstudio.guidenh.guide.document.block.LytImageBlock; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.block.LytVBox; import com.hfstudio.guidenh.guide.document.flow.InlineBlockAlignment; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; import com.hfstudio.guidenh.guide.document.interaction.ContentTooltip; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.sound.GuideSoundParsers; import com.hfstudio.guidenh.guide.sound.GuideSoundTrigger; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; -import cpw.mods.fml.common.FMLLog; - public class FloatingImageCompiler extends FlowTagCompiler { public static final String TAG_NAME = "FloatingImage"; - private static final Random RANDOM = new Random(); + private static final Random RANDOM = new Random(0); @Override public Set getTagNames() { @@ -49,33 +49,37 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen int widthPx = parseIntAttr(el, "width", -1); int heightPx = parseIntAttr(el, "height", -1); - var image = new LytImage(); + LytImageBlock block = new LytImageBlock(); + block.setStyleClass("FloatingImage"); + block.setStyle(LytParagraph.PLACEHOLDER_STYLE); + block.appendText("[FloatingImage]"); + block.setAlign(align); if (title != null) { - image.setTitle(title); + block.setTitle(title); } - image.setExplicitWidth(widthPx); - image.setExplicitHeight(heightPx); + block.setExplicitWidth(widthPx); + block.setExplicitHeight(heightPx); + + // Resolve the image src to a string identifier for later script use without + // loading the actual asset at compile time. + String resolvedSrc = null; try { var imageId = IdUtils.resolveLink(src, compiler.getPageId()); - var imageContent = compiler.loadAsset(imageId); - if (imageContent == null) { - FMLLog.getLogger() - .error("[GuideNH] [FloatingImageCompiler] Couldn't find image {}", src); - image.setTitle("Missing image: " + src); - } - image.setImage(imageId, imageContent); + resolvedSrc = imageId.toString(); } catch (IllegalArgumentException e) { - FMLLog.getLogger() - .error("[GuideNH] [FloatingImageCompiler] Invalid image id: {}", src); - image.setTitle("Invalid image URL: " + src); + GuideDebugLog.error("[GuideNH] [FloatingImageCompiler] Invalid image id: {}", src); + if (block.getTitle() == null) { + block.setTitle("Invalid image URL: " + src); + } } + block.setSrc(resolvedSrc); var wholeImageSound = GuideSoundParsers.parseAttributes(compiler, parent, el, "soundSrc"); if (wholeImageSound != null) { var soundAnnotation = new ImageRegionAnnotation(false, ConstantColor.WHITE, 1); soundAnnotation.setSound(wholeImageSound); soundAnnotation.setSoundTrigger(parseTrigger(compiler, parent, el)); - image.addAnnotation(soundAnnotation); + block.addAnnotation(soundAnnotation); } // Parse child elements. @@ -84,11 +88,11 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen for (var child : children) { if (child instanceof MdxJsxElementFields annEl && "ImageAnnotation".equals(annEl.name())) { var ann = parseImageAnnotation(compiler, parent, annEl); - image.addAnnotation(ann); + block.addAnnotation(ann); } else if (child instanceof MdxJsxElementFields soundEl && "SoundArea".equals(soundEl.name())) { var ann = parseSoundArea(compiler, parent, soundEl); if (ann != null) { - image.addAnnotation(ann); + block.addAnnotation(ann); } } } @@ -96,17 +100,17 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen // Wrap it in a flow content inline block var inlineBlock = new LytFlowInlineBlock(); - inlineBlock.setBlock(image); + inlineBlock.setBlock(block); switch (align) { case "left" -> { inlineBlock.setAlignment(InlineBlockAlignment.FLOAT_LEFT); - image.setMarginRight(5); - image.setMarginBottom(5); + block.setMarginRight(5); + block.setMarginBottom(5); } case "right" -> { inlineBlock.setAlignment(InlineBlockAlignment.FLOAT_RIGHT); - image.setMarginLeft(5); - image.setMarginBottom(5); + block.setMarginLeft(5); + block.setMarginBottom(5); } default -> { parent.append(compiler.createErrorFlowContent("Invalid align. Must be left or right.", el)); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java new file mode 100644 index 00000000..a6061cdf --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HeadingCompiler.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytHeading; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class HeadingCompiler extends BlockTagCompiler { + + private static final Set TAG_NAMES = Collections + .unmodifiableSet(new HashSet<>(Arrays.asList("h1", "h2", "h3", "h4", "h5", "h6"))); + + @Override + public Set getTagNames() { + return TAG_NAMES; + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + LytHeading heading = new LytHeading(); + int depth = parseIntSafe(el.getAttributeString("depth", "1"), 1); + heading.setDepth(Math.max(1, Math.min(depth, 6))); + compiler.compileFlowContext(el.children(), heading); + parent.append(heading); + } + + private static int parseIntSafe(String s, int fallback) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HrCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HrCompiler.java new file mode 100644 index 00000000..3adaebaf --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/HrCompiler.java @@ -0,0 +1,22 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytThematicBreak; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class HrCompiler extends BlockTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("hr"); + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + parent.append(new LytThematicBreak()); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java new file mode 100644 index 00000000..6e47a2ed --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ImageCompiler.java @@ -0,0 +1,50 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.IdUtils; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytImageBlock; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; +import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; +import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class ImageCompiler extends FlowTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("img"); + } + + @Override + protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + LytImageBlock block = new LytImageBlock(); + block.setStyleClass("Img"); + + String src = el.getAttributeString("src", ""); + if (!src.isEmpty()) { + try { + var imageId = IdUtils.resolveLink(src, compiler.getPageId()); + block.setSrc(imageId.toString()); + } catch (IllegalArgumentException e) { + GuideDebugLog.error("[GuideNH] [ImageCompiler] Invalid image id: {}", src); + block.setTitle("Invalid image URL: " + src); + } + } + + String alt = el.getAttributeString("alt", ""); + String title = el.getAttributeString("title", ""); + if (!alt.isEmpty()) block.setAlt(alt); + if (!title.isEmpty()) block.setTitle(title); + + block.setStyle(LytParagraph.PLACEHOLDER_STYLE); + block.appendText("[Image]"); + + var inlineBlock = new LytFlowInlineBlock(); + inlineBlock.setBlock(block); + parent.append(inlineBlock); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java index 9e613a70..70321019 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemGridCompiler.java @@ -1,13 +1,15 @@ package com.hfstudio.guidenh.guide.compiler.tags; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Set; import com.hfstudio.guidenh.guide.compiler.IndexingContext; import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; -import com.hfstudio.guidenh.guide.document.block.LytItemGrid; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class ItemGridCompiler extends BlockTagCompiler { @@ -19,24 +21,36 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - var itemGrid = new LytItemGrid(); + List itemIds = new ArrayList<>(); // We expect children to only contain ItemIcon elements for (var childNode : el.children()) { if (childNode instanceof MdxJsxElementFields jsxChild && "ItemIcon".equals(jsxChild.name())) { - var stack = MdxAttrs.getRequiredItemStack(compiler, parent, jsxChild); - if (stack != null) { - itemGrid.addItem(stack); + var itemId = MdxAttrs.getString(compiler, parent, jsxChild, "id", null); + if (itemId != null) { + itemIds.add(itemId); } - continue; } parent.appendError(compiler, "Unsupported child-element in ItemGrid", childNode); } - parent.append(itemGrid); + ItemGridPlaceholder placeholder = new ItemGridPlaceholder(itemIds); + parent.append(placeholder); } @Override public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) {} + + public static class ItemGridPlaceholder extends LytParagraph { + + public final List itemIds; + + public ItemGridPlaceholder(List itemIds) { + this.itemIds = itemIds; + setStyleClass("ItemGrid"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); + appendText("[ItemGrid]"); + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java index dd4d6232..fcbc0eba 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemImageCompiler.java @@ -7,7 +7,7 @@ import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.compiler.PageCompiler; -import com.hfstudio.guidenh.guide.document.block.LytItemImage; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; @@ -21,20 +21,25 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { - var stack = MdxAttrs.getRequiredItemStack(compiler, parent, el); - if (stack == null) return; + String itemId = MdxAttrs.getString(compiler, parent, el, "id", null); + String ore = MdxAttrs.getString(compiler, parent, el, "ore", null); + if (itemId == null) return; + itemId = itemId.trim(); float scale = MdxAttrs.getFloat(compiler, parent, el, "scale", 1f); - var img = new LytItemImage(stack); - img.setScale(scale); - img.setInline(true); + boolean showTooltip; + Boolean showIcon; + String labelPosition; + String labelFormat; + Integer yOffset = null; + Integer labelYOffset = null; // Allow MDX authors to nudge the icon after its default inline vertical centering. // e.g. to move it down by 2px. String yOffRaw = el.getAttributeString("yOffset", null); if (yOffRaw != null && !yOffRaw.isEmpty()) { try { - img.setInlineYOffsetOverride(Integer.parseInt(yOffRaw.trim())); + yOffset = Integer.parseInt(yOffRaw.trim()); } catch (NumberFormatException ignored) { parent.appendError(compiler, "yOffset must be an integer (pixels at scale=1)", el); } @@ -45,7 +50,7 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen String labelYOffRaw = el.getAttributeString("labelYOffset", null); if (labelYOffRaw != null && !labelYOffRaw.isEmpty()) { try { - img.setLabelYOffsetOverride(Integer.parseInt(labelYOffRaw.trim())); + labelYOffset = Integer.parseInt(labelYOffRaw.trim()); } catch (NumberFormatException ignored) { parent.appendError(compiler, "labelYOffset must be an integer (pixels at scale=1)", el); } @@ -55,27 +60,32 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen Boolean noTooltipAttr = MdxAttrs.getOptionalBoolean(el, "noTooltip"); boolean noTooltip = MdxAttrs.getBoolean(noTooltipAttr, false); Boolean showTooltipAttr = MdxAttrs.getOptionalBoolean(el, "showTooltip"); - boolean showTooltip = showTooltipAttr != null ? showTooltipAttr : !noTooltip; - img.setShowTooltip(showTooltip); + showTooltip = showTooltipAttr != null ? showTooltipAttr : !noTooltip; // showIcon — whether to render the icon graphic (default true). - Boolean showIcon = MdxAttrs.getOptionalBoolean(el, "showIcon"); - if (showIcon != null) { - img.setShowIcon(showIcon); - } + showIcon = MdxAttrs.getOptionalBoolean(el, "showIcon"); // label — "left" or "right" to display the item name next to the icon. String labelRaw = el.getAttributeString("label", null); - img.setLabelPosition(resolveLabelPosition(labelRaw)); + labelPosition = resolveLabelPosition(labelRaw); // format — Markdown-style format pattern for the label text. String formatRaw = el.getAttributeString("format", null); - if (formatRaw != null && !formatRaw.isEmpty()) { - img.setLabelFormat(formatRaw); - } + labelFormat = (formatRaw != null && !formatRaw.isEmpty()) ? formatRaw : null; + + ItemImagePlaceholder placeholder = new ItemImagePlaceholder( + itemId, + scale, + yOffset, + labelYOffset, + showTooltip, + showIcon, + labelPosition, + labelFormat, + ore); var inline = new LytFlowInlineBlock(); - inline.setBlock(img); + inline.setBlock(placeholder); parent.append(inline); } @@ -94,4 +104,38 @@ public static String resolveLabelPosition(@Nullable String raw) { return "right"; } + public static class ItemImagePlaceholder extends LytParagraph { + + public final String itemId; + public final float scale; + @Nullable + public final Integer yOffset; + @Nullable + public final Integer labelYOffset; + public final boolean showTooltip; + @Nullable + public final Boolean showIcon; + @Nullable + public final String labelPosition; + @Nullable + public final String labelFormat; + @Nullable + public final String ore; + + public ItemImagePlaceholder(String itemId, float scale, @Nullable Integer yOffset, + @Nullable Integer labelYOffset, boolean showTooltip, @Nullable Boolean showIcon, + @Nullable String labelPosition, @Nullable String labelFormat, @Nullable String ore) { + this.itemId = itemId; + this.scale = scale; + this.yOffset = yOffset; + this.labelYOffset = labelYOffset; + this.showTooltip = showTooltip; + this.showIcon = showIcon; + this.labelPosition = labelPosition; + this.labelFormat = labelFormat; + this.ore = ore; + setStyleClass("ItemImage"); + } + } + } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java index e83adb84..dabb7f75 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ItemLinkCompiler.java @@ -3,17 +3,9 @@ import java.util.Collections; import java.util.Set; -import com.hfstudio.guidenh.guide.PageAnchor; -import com.hfstudio.guidenh.guide.compiler.LinkParser; import com.hfstudio.guidenh.guide.compiler.PageCompiler; -import com.hfstudio.guidenh.guide.document.block.LytItemImage; -import com.hfstudio.guidenh.guide.document.flow.LytFlowInlineBlock; import com.hfstudio.guidenh.guide.document.flow.LytFlowLink; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; -import com.hfstudio.guidenh.guide.document.flow.LytTooltipSpan; -import com.hfstudio.guidenh.guide.document.interaction.ItemTooltip; -import com.hfstudio.guidenh.guide.indices.ItemIndex; -import com.hfstudio.guidenh.guide.indices.OreIndex; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class ItemLinkCompiler extends FlowTagCompiler { @@ -24,92 +16,50 @@ public Set getTagNames() { } @Override - public void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { - var itemAndId = MdxAttrs.getRequiredItemStackAndId(compiler, parent, el); - if (itemAndId == null) { + protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + // Extract raw attributes (no registry lookups) + String itemId = MdxAttrs.getString(compiler, parent, el, "id", null); + String ore = MdxAttrs.getString(compiler, parent, el, "ore", null); + if (itemId == null && ore == null) { + parent.appendError(compiler, "Missing id or ore attribute.", el); return; } - var stack = itemAndId.getRight(); // showTooltip — default true for ItemLink Boolean noTooltipAttr = MdxAttrs.getOptionalBoolean(el, "noTooltip"); boolean noTooltip = MdxAttrs.getBoolean(noTooltipAttr, false); Boolean showTooltipAttr = MdxAttrs.getOptionalBoolean(el, "showTooltip"); boolean showTooltip = showTooltipAttr != null ? showTooltipAttr : !noTooltip; + Boolean showTextAttr = MdxAttrs.getOptionalBoolean(el, "showText"); + boolean showText = showTextAttr == null || showTextAttr; // showIcon — null/falsy = no icon; "left", "right", or any truthy = icon at that side String showIconRaw = el.getAttributeString("showIcon", null); String iconPosition = ItemImageCompiler.resolveLabelPosition(showIconRaw); // Manual link target override: linksTo="page.md#heading" or "#heading" - PageAnchor linksTo = null; - String linksToAttr = el.getAttributeString("linksTo", null); - if (linksToAttr != null) { - PageAnchor[] holder = new PageAnchor[1]; - LinkParser.parseLink(compiler, linksToAttr, new LinkParser.Visitor() { + String linksTo = el.getAttributeString("linksTo", null); - @Override - public void handlePage(PageAnchor page) { - holder[0] = page; - } + // Create placeholder link for runtime resolution by ItemLinkScript + var link = new LytFlowLink(); + link.setStyleClass("ItemLink"); + link.setData("itemId", itemId != null ? itemId.trim() : null); + link.setData("ore", ore != null ? ore.trim() : null); + link.setData("showTooltip", showTooltip); + link.setData("showText", showText); + link.setData("showIcon", iconPosition); + link.setData("linksTo", linksTo); + link.setData( + "guideId", + compiler.getGuideId() + .toString()); + link.setData( + "pageId", + compiler.getPageId() + .toString()); - @Override - public void handleError(String error) { - parent.appendError(compiler, error, el); - } - }); - linksTo = holder[0]; - } else { - var itemAnchor = compiler.getIndex(ItemIndex.class) - .findByStack(stack); - linksTo = itemAnchor != null ? itemAnchor - : compiler.getIndex(OreIndex.class) - .findByStack(stack); - } - - // Build icon inline block if requested. - LytFlowInlineBlock iconBlock = null; - if (iconPosition != null) { - var img = new LytItemImage(stack); - img.setScale(1f); - img.setInline(true); - img.setShowTooltip(showTooltip); - iconBlock = new LytFlowInlineBlock(); - iconBlock.setBlock(img); - } - - // If the item link is already on the page we're linking to, or no page exists, - // render as an underlined tooltip span instead of a clickable link. - if (linksTo == null || linksTo.anchor() == null && compiler.getPageId() - .equals(linksTo.pageId())) { - var span = new LytTooltipSpan(); - span.modifyStyle(style -> style.italic(true)); - span.appendText(stack.getDisplayName()); - if (showTooltip) { - span.setTooltip(new ItemTooltip(stack)); - } - if ("left".equals(iconPosition)) { - parent.append(iconBlock); - } - parent.append(span); - if ("right".equals(iconPosition)) { - parent.append(iconBlock); - } - } else { - var link = new LytFlowLink(); - link.setPageLink(linksTo); - link.appendText(stack.getDisplayName()); - if (showTooltip) { - link.setTooltip(new ItemTooltip(stack)); - } - if ("left".equals(iconPosition)) { - parent.append(iconBlock); - } - parent.append(link); - if ("right".equals(iconPosition)) { - parent.append(iconBlock); - } - } + compiler.compileFlowContext(el.children(), link); + parent.append(link); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/KeyBindTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/KeyBindTagCompiler.java index 75df2548..08069e5e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/KeyBindTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/KeyBindTagCompiler.java @@ -10,6 +10,7 @@ import org.lwjgl.input.Keyboard; import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; @@ -28,13 +29,11 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen return; } - var mapping = findMapping(id); - if (mapping == null) { - parent.appendError(compiler, "No key mapping with this id was found.", el); - return; - } - - parent.appendText(describeMapping(mapping)); + var placeholder = parent.appendText(""); + placeholder.setStyleClass("KeyBind"); + placeholder.setStyle(LytParagraph.PLACEHOLDER_STYLE); + placeholder.setText("[KeyBind]"); + placeholder.setData("bindId", id); } public static String getKeyBindId(MdxJsxElementFields el) { @@ -53,6 +52,8 @@ public static String getKeyBindId(MdxJsxElementFields el) { return id.isEmpty() ? null : id; } + // --- Static helpers used by external callers (e.g. site exporter) --- + public static KeyBinding findMapping(String id) { return findMapping(Minecraft.getMinecraft().gameSettings.keyBindings, id); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListCompiler.java new file mode 100644 index 00000000..7fca31f7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListCompiler.java @@ -0,0 +1,40 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytList; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class ListCompiler extends BlockTagCompiler { + + private static final Set TAG_NAMES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("ul", "ol"))); + + @Override + public Set getTagNames() { + return TAG_NAMES; + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + boolean ordered = "ol".equals(el.name()); + int start = parseIntSafe(el.getAttributeString("start", "1"), 1); + LytList list = new LytList(ordered, start); + for (var child : el.children()) { + compiler.compileBlockContext(Collections.singletonList(child), list); + } + parent.append(list); + } + + private static int parseIntSafe(String s, int fallback) { + try { + return Integer.parseInt(s); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java new file mode 100644 index 00000000..339d64c7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/ListItemCompiler.java @@ -0,0 +1,49 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytListItem; +import com.hfstudio.guidenh.guide.document.block.LytTaskListItem; +import com.hfstudio.guidenh.guide.internal.markdown.MarkdownListSemantics; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class ListItemCompiler extends BlockTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("li"); + } + + @Override + @SuppressWarnings({ "unchecked", "rawtypes" }) + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + LytListItem listItem; + var taskMarker = MarkdownListSemantics.extractTaskMarker((List) el.children()); + if (taskMarker != null) { + LytTaskListItem taskItem = new LytTaskListItem(); + taskItem.setChecked(taskMarker.checked()); + taskMarker.textNode() + .setValue(taskMarker.remainingText()); + listItem = taskItem; + } else { + listItem = new LytListItem(); + } + compiler.compileBlockContext(el.children(), listItem); + + // Normalize first child margins + var children = listItem.getChildren(); + if (!children.isEmpty()) { + var firstChild = children.get(0); + if (firstChild instanceof LytBlock) { + ((LytBlock) firstChild).setMarginTop(0); + ((LytBlock) firstChild).setMarginBottom(0); + } + } + parent.append(listItem); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MdxAttrs.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MdxAttrs.java index 45010c17..6757a7ab 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MdxAttrs.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MdxAttrs.java @@ -3,13 +3,9 @@ import java.util.Locale; import java.util.regex.Pattern; -import net.minecraft.block.Block; -import net.minecraft.item.Item; import net.minecraft.item.ItemStack; -import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; -import org.apache.commons.lang3.tuple.Pair; import org.jetbrains.annotations.Nullable; import org.joml.Vector3f; @@ -17,7 +13,6 @@ import com.hfstudio.guidenh.guide.color.ColorValue; import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.compiler.GuideItemReferenceResolver; -import com.hfstudio.guidenh.guide.compiler.IdUtils; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.LytErrorSink; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxAttribute; @@ -72,138 +67,6 @@ public static ResourceLocation getRequiredId(PageCompiler compiler, LytErrorSink } } - @Nullable - public static Pair getRequiredItemAndId(PageCompiler compiler, LytErrorSink errorSink, - MdxJsxElementFields el, String attribute) { - var raw = getString(compiler, errorSink, el, attribute, null); - if (raw == null) { - errorSink.appendError(compiler, "Missing " + attribute + " attribute.", el); - return null; - } - raw = raw.trim(); - IdUtils.ParsedItemRef ref; - try { - ref = IdUtils.parseItemRef( - raw, - compiler.getPageId() - .getResourceDomain()); - } catch (IllegalArgumentException e) { - errorSink.appendError(compiler, "Malformed id " + raw + ": " + e.getMessage(), el); - return null; - } - if (ref == null) { - errorSink.appendError(compiler, "Missing " + attribute + " attribute.", el); - return null; - } - Item resultItem = (Item) Item.itemRegistry.getObject(ref.rawKey()); - if (resultItem == null) { - errorSink.appendError(compiler, "Missing item: " + ref.id(), el); - return null; - } - return Pair.of(ref.id(), resultItem); - } - - @Nullable - public static Pair getRequiredBlockAndId(PageCompiler compiler, LytErrorSink errorSink, - MdxJsxElementFields el, String attribute) { - String oreName = GuideItemReferenceResolver.trimToNull(getString(compiler, errorSink, el, "ore", null)); - if (oreName != null) { - ItemStack stack = GuideItemReferenceResolver.resolveOreDictionaryStack(oreName); - if (stack == null || stack.getItem() == null) { - errorSink.appendError(compiler, "Missing ore dictionary entry: " + oreName, el); - return null; - } - - Block block = Block.getBlockFromItem(stack.getItem()); - ResourceLocation blockId = GuideItemReferenceResolver.resolveBlockRegistryId(block); - if (block == null || blockId == null) { - errorSink.appendError( - compiler, - "Ore dictionary entry '" + oreName + "' does not resolve to a block item", - el); - return null; - } - return Pair.of(blockId, block); - } - - var blockId = getRequiredId(compiler, errorSink, el, attribute); - if (blockId == null) { - return null; - } - var rawAttr = getString(el, attribute, null); - String blockLookupKey = rawAttr != null && !rawAttr.trim() - .isEmpty() ? IdUtils.rawRegistryKey( - rawAttr.trim(), - compiler.getPageId() - .getResourceDomain()) - : blockId.toString(); - Block resultBlock = (Block) Block.blockRegistry.getObject(blockLookupKey); - if (resultBlock == null) { - errorSink.appendError(compiler, "Missing block: " + blockId, el); - return null; - } - return Pair.of(blockId, resultBlock); - } - - @Nullable - public static Item getRequiredItem(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, - String attribute) { - var result = getRequiredItemAndId(compiler, errorSink, el, attribute); - return result != null ? result.getRight() : null; - } - - @Nullable - public static Pair getRequiredItemStackAndId(PageCompiler compiler, - LytErrorSink errorSink, MdxJsxElementFields el) { - String oreName = GuideItemReferenceResolver.trimToNull(getString(compiler, errorSink, el, "ore", null)); - if (oreName != null) { - ItemStack stack = GuideItemReferenceResolver.resolveOreDictionaryStack(oreName); - if (stack == null || stack.getItem() == null) { - errorSink.appendError(compiler, "Missing ore dictionary entry: " + oreName, el); - return null; - } - - ResourceLocation itemId = GuideItemReferenceResolver.resolveItemRegistryId(stack); - if (itemId == null) { - errorSink.appendError(compiler, "Unregistered item from ore dictionary entry: " + oreName, el); - return null; - } - return Pair.of(itemId, stack); - } - - var raw = getString(compiler, errorSink, el, "id", null); - if (raw == null) { - errorSink.appendError(compiler, "Missing id or ore attribute.", el); - return null; - } - String idStr = raw.trim(); - IdUtils.ParsedItemRef ref; - try { - ref = IdUtils.parseItemRef( - idStr, - compiler.getPageId() - .getResourceDomain()); - } catch (IllegalArgumentException e) { - errorSink.appendError(compiler, "Malformed id " + idStr + ": " + e.getMessage(), el); - return null; - } - if (ref == null) { - errorSink.appendError(compiler, "Missing id or ore attribute.", el); - return null; - } - Item item = (Item) Item.itemRegistry.getObject(ref.rawKey()); - if (item == null) { - errorSink.appendError(compiler, "Missing item: " + ref.id(), el); - return null; - } - ItemStack stack = new ItemStack(item, 1, ref.concreteMeta()); - if (ref.nbt() != null) { - stack.stackTagCompound = (NBTTagCompound) ref.nbt() - .copy(); - } - return Pair.of(ref.id(), stack); - } - @Nullable public static GuideItemReferenceResolver.ResolvedBlockReference getRequiredBlockReference(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, String attribute) { @@ -247,13 +110,6 @@ public static GuideItemReferenceResolver.ResolvedBlockReference getBlockReferenc return GuideItemReferenceResolver.resolveBlockReference(defaultNamespace, raw, oreName); } - @Nullable - public static ItemStack getRequiredItemStack(PageCompiler compiler, LytErrorSink errorSink, - MdxJsxElementFields el) { - var result = getRequiredItemStackAndId(compiler, errorSink, el); - return result != null ? result.getValue() : null; - } - public static float getFloat(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, String name, float defaultValue) { try { @@ -278,7 +134,11 @@ public static float getFloat(MdxJsxElementFields el, String name, float defaultV return defaultValue; } try { - return Float.parseFloat(attrValue); + float value = Float.parseFloat(attrValue); + if (Float.isNaN(value) || Float.isInfinite(value)) { + throw new AttributeException(name, "Floating point value must be finite: '" + attrValue + "'"); + } + return value; } catch (NumberFormatException e) { throw new AttributeException(name, "Malformed floating point value: '" + attrValue + "'"); } @@ -404,7 +264,11 @@ public static Vector3f getVector3(PageCompiler compiler, LytErrorSink errorSink, cursor++; } try { - values[index] = Float.parseFloat(raw.substring(start, cursor)); + float value = Float.parseFloat(raw.substring(start, cursor)); + if (Float.isNaN(value) || Float.isInfinite(value)) { + return null; + } + values[index] = value; } catch (NumberFormatException e) { return null; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java index 7e24f0e6..502e1cb8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/MermaidCompiler.java @@ -1,10 +1,7 @@ package com.hfstudio.guidenh.guide.compiler.tags; -import java.nio.charset.StandardCharsets; -import java.util.ArrayDeque; import java.util.Collections; import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; import java.util.Set; @@ -16,17 +13,13 @@ import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlock; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; -import com.hfstudio.guidenh.guide.document.block.LytMermaidMindmap; import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.block.LytVBox; -import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapNode; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapNodeContentExtractor; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapParser; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; -import cpw.mods.fml.common.FMLLog; - public class MermaidCompiler extends BlockTagCompiler { @Override @@ -36,142 +29,89 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - String source = resolveSource(compiler, parent, el); - if (source == null || source.trim() - .isEmpty()) { - parent.appendError(compiler, "Mermaid requires inline content or a non-empty src attribute.", el); - return; - } - - try { - var document = MermaidMindmapParser.parse(source); - Map nodeContentBlocks = compileNodeContentBlocks( - compiler, - parent, - el, - document.getRoot()); - LytMermaidMindmap block = new LytMermaidMindmap(document, source, nodeContentBlocks); - int width = MdxAttrs.getInt(compiler, parent, el, "width", 0); - int height = MdxAttrs.getInt(compiler, parent, el, "height", 0); - if (width > 0 || height > 0) { - block.setPreferredSize(width, height); - } - FMLLog.getLogger() - .debug( - "[GuideNH] [MermaidCompiler] Compiled Mermaid runtime block for page {} with root='{}', children={}, sourceLength={}, width={}, height={}", - compiler.getPageId(), - document.getRoot() - .getText(), - document.getRoot() - .getChildren() - .size(), - source.length(), - width, - height); - parent.append(block); - } catch (IllegalArgumentException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [MermaidCompiler] Failed to compile Mermaid runtime block for page {} from source: {}", - compiler.getPageId(), - source, - e); - parent.appendError(compiler, "Unsupported Mermaid runtime block: " + e.getMessage(), el); - } - } - - @Override - public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) { - String source = resolveSource(indexer, el); - if (source != null && !source.trim() - .isEmpty()) { - sink.appendText(el, source); - sink.appendBreak(); - } - } + String src = null; + String sourceText = null; - private String resolveSource(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - String src; + String srcStr; try { - src = MdxAttrs.getString(el, "src", null); + srcStr = MdxAttrs.getString(el, "src", null); } catch (MdxAttrs.AttributeException e) { parent.appendError(compiler, e.getMessage(), el); - return null; + return; } - if (src != null && !src.trim() + + if (srcStr != null && !srcStr.trim() .isEmpty()) { - return loadSource(compiler, src.trim()); + ResourceLocation mermaidId; + try { + mermaidId = IdUtils.resolveLink(srcStr.trim(), compiler.getPageId()); + } catch (IllegalArgumentException e) { + parent.appendError(compiler, "Malformed Mermaid src: " + srcStr, el); + return; + } + src = mermaidId.toString(); + } else { + // Prefer raw source text so Mermaid DSL syntax (brackets, links, etc.) + // is not consumed by markdown parsing. PR #24. + String rawSource = compiler.getBlockTagChildrenSource(el); + if (rawSource != null) { + sourceText = MermaidMindmapParser.normalize(stripNodeContentBlocks(rawSource)); + } else { + sourceText = MermaidMindmapNodeContentExtractor.extractDiagramSource(el.children()); + } } - String rawTagBodySource = compiler.getBlockTagChildrenSource(el); - if (rawTagBodySource != null && !rawTagBodySource.trim() - .isEmpty()) { - return MermaidMindmapNodeContentExtractor.stripExplicitNodeContentBlocks(rawTagBodySource); + + if ((sourceText == null || sourceText.trim() + .isEmpty()) && src == null) { + parent.appendError(compiler, "Mermaid requires inline content or a non-empty src attribute.", el); + return; } - return MermaidMindmapNodeContentExtractor.extractDiagramSource(el.children()); + + int width = MdxAttrs.getInt(compiler, parent, el, "width", 0); + int height = MdxAttrs.getInt(compiler, parent, el, "height", 0); + + Map nodeContentBlocks = compileNodeContentBlocks(compiler, parent, el); + + MermaidPlaceholder placeholder = new MermaidPlaceholder(src, sourceText, width, height, nodeContentBlocks); + placeholder.appendText("[Mermaid]"); + parent.append(placeholder); } - private String resolveSource(IndexingContext indexer, MdxJsxElementFields el) { + @Override + public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) { + // NB: Phase 2 loaded src-based Mermaid content and indexed the actual diagram source. + // Phase 3 src-based content is resolved at MOUNT time by MermaidScript, so index() only + // indexes the src path string. Inline content (no src attribute) is still indexed here. + // Full indexing for src-based mermaid requires a post-mount indexing pass (TBD). String src; try { src = MdxAttrs.getString(el, "src", null); } catch (MdxAttrs.AttributeException e) { - return null; + src = null; } if (src != null && !src.trim() .isEmpty()) { - return loadSource(indexer, src.trim()); - } - return MermaidMindmapNodeContentExtractor.extractDiagramSource(el.children()); - } - - private String loadSource(PageCompiler compiler, String src) { - try { - ResourceLocation mermaidId = IdUtils.resolveLink(src, compiler.getPageId()); - byte[] data = compiler.loadAsset(mermaidId); - if (data == null) { - FMLLog.getLogger() - .warn( - "[GuideNH] [MermaidCompiler] Mermaid src '{}' for page {} could not be loaded as asset {}", - src, - compiler.getPageId(), - mermaidId); - return null; + sink.appendText(el, src); + sink.appendBreak(); + } else { + String inlineSource = MermaidMindmapNodeContentExtractor.extractDiagramSource(el.children()); + if (inlineSource != null && !inlineSource.trim() + .isEmpty()) { + sink.appendText(el, inlineSource); + sink.appendBreak(); } - String loaded = MermaidMindmapParser.normalize(new String(data, StandardCharsets.UTF_8)); - FMLLog.getLogger() - .debug( - "[GuideNH] [MermaidCompiler] Loaded Mermaid src '{}' for page {} as asset {} ({} chars)", - src, - compiler.getPageId(), - mermaidId, - loaded.length()); - return loaded; - } catch (IllegalArgumentException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [MermaidCompiler] Failed to resolve Mermaid src '{}' for page {}", - src, - compiler.getPageId(), - e); - return null; - } - } - - private String loadSource(IndexingContext indexer, String src) { - try { - ResourceLocation mermaidId = IdUtils.resolveLink(src, indexer.getPageId()); - byte[] data = indexer.loadAsset(mermaidId); - return data != null ? MermaidMindmapParser.normalize(new String(data, StandardCharsets.UTF_8)) : null; - } catch (IllegalArgumentException e) { - return null; } } private Map compileNodeContentBlocks(PageCompiler compiler, LytBlockContainer parent, - MdxJsxElementFields mermaidElement, MermaidMindmapNode root) { - Map nodesById = indexNodesById(root); - Map explicitBlocksById = new LinkedHashMap<>(); + MdxJsxElementFields mermaidElement) { + // NB: Phase 2 cross-referenced NodeContent IDs against the parsed MermaidMindmapNode tree + // (via indexNodesById), validated unknown IDs, and provided inline-markdown fallback for + // nodes without explicit NodeContent. Phase 3 defers tree construction to MermaidScript + // (MOUNT time), so cross-validation must happen at runtime. See MermaidScript for the + // runtime counterpart. + Map result = new LinkedHashMap<>(); for (MdxJsxFlowElement child : MermaidMindmapNodeContentExtractor .collectNodeContentElements(mermaidElement.children())) { String id = MermaidMindmapNodeContentExtractor.readNodeContentId(child); @@ -179,66 +119,76 @@ private Map compileNodeContentBlocks(PageCompiler compiler, Ly parent.appendError(compiler, "Mermaid requires a non-empty id attribute.", child); continue; } - if (!nodesById.containsKey(id)) { - parent.appendError(compiler, "Mermaid references unknown node id '" + id + "'.", child); - continue; - } - if (explicitBlocksById.put(id, child) != null) { - parent.appendError(compiler, "Duplicate Mermaid id '" + id + "'.", child); - } - } - - Map result = new LinkedHashMap<>(); - for (MermaidMindmapNode node : nodesById.values()) { - LytBlock compiled = compileNodeContentBlock(compiler, explicitBlocksById.get(node.getId()), node); + LytBlock compiled = compileNodeContentBlock(compiler, child); if (compiled != null) { - result.put(node.getId(), compiled); + result.put(id, compiled); } } return result; } - private Map indexNodesById(MermaidMindmapNode root) { - Map nodesById = new LinkedHashMap<>(); - ArrayDeque pending = new ArrayDeque<>(); - pending.add(root); - while (!pending.isEmpty()) { - MermaidMindmapNode node = pending.removeFirst(); - nodesById.putIfAbsent(node.getId(), node); - List children = node.getChildren(); - for (MermaidMindmapNode child : children) { - pending.addLast(child); - } + private LytBlock compileNodeContentBlock(PageCompiler compiler, MdxJsxFlowElement explicitContent) { + if (explicitContent == null) { + return null; } - return nodesById; + LytVBox box = new LytVBox(); + compiler.withBlockTagChildrenSourceContext( + explicitContent, + () -> compiler.compileBlockContext(explicitContent.children(), box)); + return box.getChildren() + .isEmpty() ? null : box; } - private LytBlock compileNodeContentBlock(PageCompiler compiler, MdxJsxFlowElement explicitContent, - MermaidMindmapNode node) { - if (explicitContent != null) { - LytVBox box = new LytVBox(); - compiler.withBlockTagChildrenSourceContext( - explicitContent, - () -> compiler.compileBlockContext(explicitContent.children(), box)); - return box.getChildren() - .isEmpty() ? null : box; - } - if (!shouldCompileRichInlineLabel(node)) { - return null; + public static class MermaidPlaceholder extends LytParagraph { + + public final String src; + public final String sourceText; + public final int width; + public final int height; + public final Map nodeContentBlocks; + + public MermaidPlaceholder(String src, String sourceText, int width, int height, + Map nodeContentBlocks) { + this.src = src; + this.sourceText = sourceText; + this.width = width; + this.height = height; + this.nodeContentBlocks = nodeContentBlocks; + setStyleClass("Mermaid"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); } - LytParagraph paragraph = new LytParagraph(); - compiler.withSourceContext( - node.getLabelSource(), - () -> compiler.compileInlineMarkdown(node.getLabelSource(), paragraph)); - return paragraph.isEmpty() ? null : paragraph; } - private boolean shouldCompileRichInlineLabel(MermaidMindmapNode node) { - String labelSource = node.getLabelSource(); - if (labelSource == null || labelSource.trim() - .isEmpty()) { - return false; + private static String stripNodeContentBlocks(String source) { + StringBuilder result = new StringBuilder(source.length()); + int depth = 0; + for (int i = 0; i < source.length(); i++) { + char c = source.charAt(i); + if (depth == 0 && source.startsWith("<", i)) { + int tagEnd = source.indexOf('>', i); + if (tagEnd > i) { + String tag = source.substring(i, tagEnd + 1); + if (tag.startsWith(" 0) { + if (source.startsWith("", i)) { + depth--; + if (depth == 0) { + i += "".length() - 1; + continue; + } + } else if (source.startsWith(" getTagNames() { + return Collections.singleton("p"); + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + LytParagraph paragraph = new LytParagraph(); + compiler.compileFlowContext(el.children(), paragraph); + paragraph.setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + paragraph.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + parent.append(paragraph); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PlayerNameTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PlayerNameTagCompiler.java index d002b961..e0479809 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PlayerNameTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PlayerNameTagCompiler.java @@ -3,9 +3,8 @@ import java.util.Collections; import java.util.Set; -import net.minecraft.client.Minecraft; - import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; @@ -21,9 +20,9 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { - var playerName = Minecraft.getMinecraft() - .getSession() - .getUsername(); - parent.appendText(playerName); + var placeholder = parent.appendText(""); + placeholder.setStyleClass("PlayerName"); + placeholder.setStyle(LytParagraph.PLACEHOLDER_STYLE); + placeholder.setText("[PlayerName]"); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java new file mode 100644 index 00000000..f1aa2665 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/PreCompiler.java @@ -0,0 +1,264 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jetbrains.annotations.Nullable; + +import com.github.bsideup.jabel.Desugar; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.tags.functiongraph.FunctionGraphFenceParser; +import com.hfstudio.guidenh.guide.document.block.LytBlock; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytCodeBlock; +import com.hfstudio.guidenh.guide.document.block.LytMermaidMindmap; +import com.hfstudio.guidenh.guide.internal.csv.CsvTableParser; +import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockLanguage; +import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockLanguageDetector; +import com.hfstudio.guidenh.guide.internal.markdown.FileTreeCompiler; +import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapParser; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.model.MdAstText; + +public class PreCompiler extends BlockTagCompiler { + + private static final Pattern CODEBLOCK_META_WIDTH = Pattern.compile("(^|\\s)width=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); + private static final Pattern CODEBLOCK_META_HEIGHT = Pattern + .compile("(^|\\s)height=(\"([^\"]+)\"|'([^']+)'|(\\S+))"); + + @Override + public Set getTagNames() { + return Collections.singleton("pre"); + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + // Extract code text from children — should be a single MdAstText child + String codeText = ""; + var children = el.children(); + if (!children.isEmpty() && children.get(0) instanceof MdAstText text) { + codeText = text.value; + } + + String lang = el.getAttributeString("lang", null); + String meta = el.getAttributeString("meta", null); + + CodeBlockLanguage language = CodeBlockLanguageDetector.detect(lang, codeText); + + // CSV table + if (lang != null && "csv".equals(language.id())) { + LytBlock csvBlock = compileCsvCodeBlock(compiler, codeText, meta); + parent.append(csvBlock); + return; + } + + // File tree + if (isFileTreeFence(lang)) { + parent.append(FileTreeCompiler.compile(compiler, codeText)); + return; + } + + // Function graph + if (isFunctionGraphFence(lang)) { + parent.append(FunctionGraphFenceParser.parse(codeText)); + return; + } + + // Mermaid + if ("mermaid".equals(language.id())) { + LytMermaidMindmap mermaidBlock = tryCompileMermaidMindmap(codeText); + if (mermaidBlock != null) { + parent.append(mermaidBlock); + return; + } + } + + // Default code block with syntax highlighting + LytCodeBlock codeBlock = new LytCodeBlock(); + codeBlock.setCodeContent(lang != null ? lang : language.id(), codeText); + codeBlock.applyLanguage(language); + Integer preferredWidth = parseCodeBlockWidth(meta); + if (preferredWidth != null) { + codeBlock.setPreferredBodyWidth(preferredWidth); + } + Integer forcedHeight = parseCodeBlockHeight(meta); + if (forcedHeight != null) { + codeBlock.setForcedBodyHeight(forcedHeight); + } + parent.append(codeBlock); + } + + // ---- CSV code block compilation ---- + + private LytBlock compileCsvCodeBlock(PageCompiler compiler, String source, @Nullable String meta) { + List> rows = CsvTableParser.parse(source); + if (rows.isEmpty()) { + LytCodeBlock codeBlock = new LytCodeBlock(); + codeBlock.setCodeContent("csv", source); + codeBlock.applyLanguage(new CodeBlockLanguage("csv", "CSV")); + return codeBlock; + } + + CsvFenceMeta csvMeta = parseCsvFenceMeta(meta); + return CsvTableCompiler.buildTable(compiler, rows, csvMeta.header(), csvMeta.widthHints()); + } + + private CsvFenceMeta parseCsvFenceMeta(@Nullable String meta) { + if (meta == null || meta.trim() + .isEmpty()) { + return new CsvFenceMeta(true, Collections.emptyList()); + } + + boolean header = true; + List widthHints = Collections.emptyList(); + for (String token : splitMetaTokens(meta)) { + int equalsIndex = token.indexOf('='); + if (equalsIndex <= 0 || equalsIndex == token.length() - 1) { + continue; + } + + String key = token.substring(0, equalsIndex); + String value = stripOptionalQuotes(token.substring(equalsIndex + 1)); + if ("widths".equals(key)) { + widthHints = CsvTableCompiler.parseWidthHints(value); + } else if ("header".equals(key)) { + header = !"false".equalsIgnoreCase(value); + } + } + + return new CsvFenceMeta(header, widthHints); + } + + private List splitMetaTokens(String meta) { + List tokens = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inQuotes = false; + char quote = 0; + for (int i = 0; i < meta.length(); i++) { + char ch = meta.charAt(i); + if ((ch == '"' || ch == '\'') && (!inQuotes || ch == quote)) { + if (inQuotes && ch == quote) { + inQuotes = false; + quote = 0; + } else if (!inQuotes) { + inQuotes = true; + quote = ch; + } + current.append(ch); + continue; + } + if (Character.isWhitespace(ch) && !inQuotes) { + if (current.length() > 0) { + tokens.add(current.toString()); + current.setLength(0); + } + continue; + } + current.append(ch); + } + if (current.length() > 0) { + tokens.add(current.toString()); + } + return tokens; + } + + private String stripOptionalQuotes(String value) { + if (value.length() >= 2) { + char first = value.charAt(0); + char last = value.charAt(value.length() - 1); + if ((first == '"' && last == '"') || (first == '\'' && last == '\'')) { + return value.substring(1, value.length() - 1); + } + } + return value; + } + + @Desugar + private record CsvFenceMeta(boolean header, List widthHints) {} + + // ---- Mermaid ---- + + private @Nullable LytMermaidMindmap tryCompileMermaidMindmap(String source) { + try { + String normalized = MermaidMindmapParser.normalize(source); + LytMermaidMindmap block = new LytMermaidMindmap(MermaidMindmapParser.parse(normalized), normalized); + GuideDebugLog + .debug("[GuideNH] [PreCompiler] Compiled fenced Mermaid runtime block ({} chars)", normalized.length()); + return block; + } catch (IllegalArgumentException e) { + GuideDebugLog.error( + "[GuideNH] [PreCompiler] Failed to parse fenced Mermaid runtime block from source: {}", + source, + e); + return null; + } + } + + // ---- Static helpers (copied from PageCompiler) ---- + + private static boolean isFileTreeFence(@Nullable String fenceLanguage) { + if (fenceLanguage == null) { + return false; + } + String trimmed = fenceLanguage.trim(); + return "tree".equalsIgnoreCase(trimmed) || "filetree".equalsIgnoreCase(trimmed); + } + + private static boolean isFunctionGraphFence(@Nullable String fenceLanguage) { + if (fenceLanguage == null) { + return false; + } + String trimmed = fenceLanguage.trim(); + return "funcgraph".equalsIgnoreCase(trimmed) || "function".equalsIgnoreCase(trimmed) + || "functiongraph".equalsIgnoreCase(trimmed); + } + + private static @Nullable Integer parseCodeBlockWidth(@Nullable String meta) { + if (meta == null || meta.trim() + .isEmpty()) { + return null; + } + Matcher matcher = CODEBLOCK_META_WIDTH.matcher(meta); + if (!matcher.find()) { + return null; + } + String value = matcher.group(3) != null ? matcher.group(3) + : matcher.group(4) != null ? matcher.group(4) : matcher.group(5); + if (value == null || value.trim() + .isEmpty()) { + return null; + } + try { + return Math.max(0, Integer.parseInt(value.trim())); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static @Nullable Integer parseCodeBlockHeight(@Nullable String meta) { + if (meta == null || meta.trim() + .isEmpty()) { + return null; + } + Matcher matcher = CODEBLOCK_META_HEIGHT.matcher(meta); + if (!matcher.find()) { + return null; + } + String value = matcher.group(3) != null ? matcher.group(3) + : matcher.group(4) != null ? matcher.group(4) : matcher.group(5); + if (value == null || value.trim() + .isEmpty()) { + return null; + } + try { + return Math.max(0, Integer.parseInt(value.trim())); + } catch (NumberFormatException ignored) { + return null; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java index 0d818617..397e2476 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/RecipeCompiler.java @@ -1,8 +1,9 @@ package com.hfstudio.guidenh.guide.compiler.tags; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; -import java.util.IdentityHashMap; import java.util.List; import java.util.Locale; import java.util.Set; @@ -11,7 +12,6 @@ import net.minecraft.item.Item; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NBTBase; -import net.minecraft.nbt.NBTTagCompound; import org.jetbrains.annotations.Nullable; @@ -21,25 +21,19 @@ import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; import com.hfstudio.guidenh.guide.document.block.LytHBox; import com.hfstudio.guidenh.guide.document.block.LytParagraph; -import com.hfstudio.guidenh.guide.document.block.recipes.LytStandardRecipeBox; -import com.hfstudio.guidenh.guide.internal.recipe.LytNeiRecipeBox; -import com.hfstudio.guidenh.guide.internal.recipe.RecipeCache; import com.hfstudio.guidenh.guide.internal.recipe.RecipeLookup; -import com.hfstudio.guidenh.integration.api.GuideNhIntegrationRegistry; import com.hfstudio.guidenh.integration.api.RecipeEntry; import com.hfstudio.guidenh.integration.api.RecipeSlot; import com.hfstudio.guidenh.integration.nei.NeiRecipeLookup; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; -import cpw.mods.fml.common.FMLLog; - public class RecipeCompiler extends BlockTagCompiler { public static final int MULTI_GAP = 4; @Override public Set getTagNames() { - return new HashSet<>(Set.of("Recipe", "RecipeFor", "RecipeUsage", "RecipesFor")); + return new HashSet<>(Arrays.asList("Recipe", "RecipeFor", "RecipeUsage", "RecipesFor")); } @Override @@ -65,25 +59,10 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl parent.appendError(compiler, "Blank id", el); return; } - Item item = (Item) Item.itemRegistry.getObject(ref.rawKey()); - if (item == null) { - if (fallbackText != null) { - if (!fallbackText.isEmpty()) parent.append(LytParagraph.of(fallbackText)); - } else { - parent.appendError(compiler, "Missing item: " + ref.id(), el); - } - return; - } - - boolean multi = "RecipesFor".equals(el.name()); - boolean usageQuery = "RecipeUsage".equals(el.name()); - // Build the concrete query stack with meta + nbt (wildcard meta collapses to 0). - ItemStack targetStack = new ItemStack(item, 1, ref.concreteMeta()); - if (ref.nbt() != null) { - targetStack.stackTagCompound = (NBTTagCompound) ref.nbt() - .copy(); - } + String tagName = el.name(); + boolean multi = "RecipesFor".equals(tagName); + boolean usageQuery = "RecipeUsage".equals(tagName); String handlerNameFilter = trimToNull(MdxAttrs.getString(compiler, parent, el, "handlerName", null)); String handlerIdFilter = trimToNull(MdxAttrs.getString(compiler, parent, el, "handlerId", null)); @@ -126,141 +105,26 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl return; } } - boolean hasRecipeFilter = !inputExpr.isEmpty() || !outputExpr.isEmpty(); - boolean hasHandlerFilter = handlerNameFilter != null || handlerIdFilter != null || handlerOrder >= 0; - - // Prefer NEI-native handler rendering if available. - List rawHandlers = usageQuery ? RecipeCache.getUsageHandlers(targetStack) - : RecipeCache.getCraftingHandlers(targetStack); - // When a handler filter is specified, also consult usage handlers. This covers NEI handlers - // that treat the target as an input rather than an output. Anvil, repair, fuel, and brewing - // ingredients never show up under getCraftingHandlers. - if (!usageQuery && hasHandlerFilter) { - List usage = RecipeCache.getUsageHandlers(targetStack); - if (!usage.isEmpty()) { - if (rawHandlers.isEmpty()) { - rawHandlers = usage; - } else { - List merged = new ArrayList<>(rawHandlers.size() + usage.size()); - merged.addAll(rawHandlers); - // Dedup by identity because the same IRecipeHandler instance may appear in both lists. - IdentityHashMap seen = new IdentityHashMap<>(merged.size()); - for (Object h : rawHandlers) seen.put(h, Boolean.TRUE); - for (Object h : usage) if (seen.put(h, Boolean.TRUE) == null) merged.add(h); - rawHandlers = merged; - } - } - } - List handlers = filterHandlers(rawHandlers, handlerNameFilter, handlerIdFilter, handlerOrder); - if (!handlers.isEmpty()) { - List boxes = new ArrayList<>(); - for (int hi = 0; hi < handlers.size() && boxes.size() < limit; hi++) { - Object handler = handlers.get(hi); - int num = GuideNhIntegrationRegistry.global() - .lookupRecipeHandlerRecipeCount(handler); - int recipeStart = Math.max(exactRecipeIndex, 0); - int recipeEnd = exactRecipeIndex >= 0 ? Math.min(num, exactRecipeIndex + 1) : num; - for (int ri = recipeStart; ri < recipeEnd && boxes.size() < limit; ri++) { - if (hasRecipeFilter && !recipeMatches(handler, ri, inputExpr, outputExpr)) continue; - boxes.add(new LytNeiRecipeBox(handler, ri, !usageQuery)); - } - } - if (!boxes.isEmpty()) { - appendRecipes(parent, boxes, multi); - return; - } - if (exactRecipeIndex >= 0) { - appendRecipeNotFoundFallback(compiler, parent, el, fallbackText, usageQuery, ref); - return; - } - } else if (hasHandlerFilter) { - // Handler filter eliminated every candidate. Respect fallbackText (if any) and bail quietly. - // this is a legitimate authoring case (e.g. "only render when NEI + the relevant handler is - // installed") and should not spam error overlays. - if (fallbackText != null && !fallbackText.isEmpty()) { - parent.append(LytParagraph.of(fallbackText)); - } else if (FMLLog.getLogger() - .isDebugEnabled()) { - FMLLog.getLogger() - .debug( - "[GuideNH] [RecipeCompiler] No NEI handler matched filters for {} (handlerName={}, handlerId={}, handlerOrder={})", - ref.id(), - handlerNameFilter, - handlerIdFilter, - handlerOrder); - } - return; - } - - // Legacy fallback: raw slot data coming from NEI (no handler draw) or from vanilla crafting registry. - List recipeEntries = usageQuery ? List.of() - : GuideNhIntegrationRegistry.global() - .findCraftingRecipeEntries(targetStack); - if (!recipeEntries.isEmpty()) { - List boxes = new ArrayList<>(); - int entryStart = Math.max(exactRecipeIndex, 0); - int entryEnd = exactRecipeIndex >= 0 ? Math.min(recipeEntries.size(), exactRecipeIndex + 1) - : recipeEntries.size(); - for (int i = entryStart; i < entryEnd && boxes.size() < limit; i++) { - var e = recipeEntries.get(i); - if (e.result() == null || e.ingredients() - .isEmpty()) continue; - if (hasRecipeFilter && !entryMatches(e, inputExpr, outputExpr)) continue; - List flat = new ArrayList<>(9); - for (int s = 0; s < 9; s++) flat.add(null); - int idx = 0; - for (RecipeSlot slot : e.ingredients()) { - if (idx >= 9) break; - if (slot.stacks() != null && !slot.stacks() - .isEmpty()) flat.set( - idx, - slot.stacks() - .get(0)); - idx++; - } - ItemStack resultStack = e.result() - .stacks() != null - && !e.result() - .stacks() - .isEmpty() ? e.result() - .stacks() - .get(0) : null; - if (resultStack != null) boxes.add(LytStandardRecipeBox.shapeless(flat, resultStack)); - } - if (!boxes.isEmpty()) { - appendRecipes(parent, boxes, multi); - return; - } - } - - List entries = usageQuery ? List.of() : RecipeLookup.findByOutput(item); - if (entries.isEmpty()) { - if (fallbackText != null) { - if (!fallbackText.isEmpty()) parent.append(LytParagraph.of(fallbackText)); - } else { - parent.appendError( - compiler, - "Couldn't find " + (usageQuery ? "usage" : "recipe") + " for " + ref.id(), - el); - } - return; - } - List boxes = new ArrayList<>(); - int vanillaStart = Math.max(exactRecipeIndex, 0); - int vanillaEnd = exactRecipeIndex >= 0 ? Math.min(entries.size(), exactRecipeIndex + 1) : entries.size(); - for (int i = vanillaStart; i < vanillaEnd && boxes.size() < limit; i++) { - var e = entries.get(i); - if (hasRecipeFilter && !vanillaEntryMatches(e, inputExpr, outputExpr)) continue; - var box = e.shapeless ? LytStandardRecipeBox.shapeless(RecipeLookup.asList(e), e.result) - : LytStandardRecipeBox.shaped3x3(RecipeLookup.asList(e), e.result); - boxes.add(box); - } - if (!boxes.isEmpty()) { - appendRecipes(parent, boxes, multi); - return; - } - appendRecipeNotFoundFallback(compiler, parent, el, fallbackText, usageQuery, ref); + // RecipePlaceholder -- recipe resolution deferred to RecipeScript + RecipePlaceholder ph = new RecipePlaceholder( + tagName, + idStr, + ref, + fallbackText, + handlerNameFilter, + handlerIdFilter, + handlerOrder, + exactRecipeIndex, + inputExpr, + outputExpr, + limit, + multi, + usageQuery); + ph.setStyleClass(tagName); + ph.setStyle(LytParagraph.PLACEHOLDER_STYLE); + ph.appendText("[Recipe]"); + parent.append(ph); } /** @@ -285,11 +149,6 @@ public static void appendRecipes(LytBlockContainer parent, List filterHandlers(List raw, @Nullable String nameFilter, @Nullable String idFilter, - int order) { - return filterHandlers(raw, nameFilter, idFilter, order, NEI_HANDLER_METADATA_READER); - } - public static List filterHandlers(List raw, @Nullable String nameFilter, @Nullable String idFilter, int order, HandlerMetadataReader metadataReader) { if (raw.isEmpty()) return raw; @@ -360,19 +219,19 @@ private FilterTerm(IdUtils.ParsedItemRef ref, boolean negated) { /** * Disjunctive-normal-form filter expression: - * + * *
      *   expr  := orGroup ( ',' orGroup )*   // any group satisfied -> expression holds
      *   group := term    ( '&' term    )*   // every term in the group must be satisfied
      *   term  := [ '!' ] itemRef            // '!' flips the match sense
      * 
- * + * * Empty expression (from an absent/blank attribute) means "no filter" and is cheap to check * via {@link #isEmpty()}. */ public static class FilterExpr { - private static final FilterExpr EMPTY = new FilterExpr(List.of()); + private static final FilterExpr EMPTY = new FilterExpr(Collections.>emptyList()); private final List> orGroups; private FilterExpr(List> orGroups) { @@ -404,153 +263,45 @@ public interface HandlerRecipeAccess { NeiRecipeLookup.Slot readResultSlot(Object handler, int recipeIndex); } - public interface RecipeSlotAccess { - - List readIngredientSlots(Object handler, int recipeIndex); - - @Nullable - RecipeSlot readResultSlot(Object handler, int recipeIndex); - } - - private static final HandlerMetadataReader NEI_HANDLER_METADATA_READER = new HandlerMetadataReader() { - - @Override - public @Nullable String handlerName(Object handler) { - return GuideNhIntegrationRegistry.global() - .lookupRecipeHandlerName(handler); - } - - @Override - public @Nullable String handlerId(Object handler) { - return GuideNhIntegrationRegistry.global() - .lookupRecipeHandlerId(handler); - } - - @Override - public @Nullable String overlayIdentifier(Object handler) { - return GuideNhIntegrationRegistry.global() - .lookupRecipeHandlerOverlayIdentifier(handler); - } - }; - - private static void appendRecipeNotFoundFallback(PageCompiler compiler, LytBlockContainer parent, - MdxJsxElementFields el, @Nullable String fallbackText, boolean usageQuery, IdUtils.ParsedItemRef ref) { - if (fallbackText != null) { - if (!fallbackText.isEmpty()) parent.append(LytParagraph.of(fallbackText)); - return; - } - parent.appendError(compiler, "Couldn't find " + (usageQuery ? "usage" : "recipe") + " for " + ref.id(), el); - } - - private static final RecipeSlotAccess REGISTRY_RECIPE_SLOT_ACCESS = new RecipeSlotAccess() { - - @Override - public List readIngredientSlots(Object handler, int recipeIndex) { - return GuideNhIntegrationRegistry.global() - .readRecipeIngredientSlots(handler, recipeIndex); - } - - @Override - public @Nullable RecipeSlot readResultSlot(Object handler, int recipeIndex) { - return GuideNhIntegrationRegistry.global() - .readRecipeResultSlot(handler, recipeIndex); - } - }; - /** - * Parse an {@code input} / {@code output} attribute supporting OR (',') + AND ('&') + NOT ('!'). - * Malformed tokens emit a compile error and are skipped; a group with no surviving terms is - * dropped, and if every group is dropped the result is {@link FilterExpr#EMPTY} (i.e. "no - * filter", which is safer than "always fail"). + * A placeholder paragraph that carries all extracted recipe query attributes. + * Actual recipe resolution is deferred to RecipeScript. */ - public static FilterExpr parseFilterExpr(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el, - String attr, String defaultNs) { - String raw = trimToNull(MdxAttrs.getString(compiler, parent, el, attr, null)); - return parseFilterExpr(raw, attr, defaultNs, message -> parent.appendError(compiler, message, el)); - } - - public static FilterExpr parseFilterExpr(@Nullable String raw, String defaultNs) { - return parseFilterExpr(raw, null, defaultNs, null); - } - - private static FilterExpr parseFilterExpr(@Nullable String raw, @Nullable String attr, String defaultNs, - @Nullable Consumer errorSink) { - if (raw == null) return FilterExpr.EMPTY; - List> groups = new ArrayList<>(); - int rawLength = raw.length(); - int orStart = 0; - while (orStart <= rawLength) { - int orEnd = raw.indexOf(',', orStart); - if (orEnd < 0) { - orEnd = rawLength; - } - String orTrim = raw.substring(orStart, orEnd) - .trim(); - if (!orTrim.isEmpty()) { - List andTerms = new ArrayList<>(); - parseFilterTerms(orTrim, attr, defaultNs, errorSink, andTerms); - if (!andTerms.isEmpty()) groups.add(andTerms); - } - if (orEnd == rawLength) { - break; - } - orStart = orEnd + 1; - } - return groups.isEmpty() ? FilterExpr.EMPTY : new FilterExpr(groups); - } - - private static void parseFilterTerms(String orTrim, @Nullable String attr, String defaultNs, - @Nullable Consumer errorSink, List andTerms) { - int andLength = orTrim.length(); - int andStart = 0; - while (andStart <= andLength) { - int andEnd = orTrim.indexOf('&', andStart); - if (andEnd < 0) { - andEnd = andLength; - } - parseFilterTerm( - orTrim.substring(andStart, andEnd) - .trim(), - attr, - defaultNs, - errorSink, - andTerms); - if (andEnd == andLength) { - break; - } - andStart = andEnd + 1; - } - } - - private static void parseFilterTerm(String token, @Nullable String attr, String defaultNs, - @Nullable Consumer errorSink, List andTerms) { - if (token.isEmpty()) return; - boolean negated = false; - if (token.startsWith("!")) { - negated = true; - token = token.substring(1) - .trim(); - if (token.isEmpty()) { - if (errorSink != null) { - errorSink.accept("Empty " + filterAttrName(attr) + " negation token '!' has no id"); - } - return; - } - } - try { - IdUtils.ParsedItemRef p = IdUtils.parseItemRef(token, defaultNs); - if (p != null) andTerms.add(new FilterTerm(p, negated)); - } catch (IllegalArgumentException e) { - if (errorSink != null) { - errorSink.accept("Malformed " + filterAttrName(attr) + " filter '" + token + "': " + e.getMessage()); - } + public static class RecipePlaceholder extends LytParagraph { + + public final String tagName; + public final String idStr; + public final IdUtils.ParsedItemRef ref; + public final String fallbackText; + public final String handlerName; + public final String handlerId; + public final int handlerOrder; + public final int recipeIndex; + public final FilterExpr inputExpr; + public final FilterExpr outputExpr; + public final int limit; + public final boolean multi; + public final boolean usageQuery; + + public RecipePlaceholder(String tagName, String idStr, IdUtils.ParsedItemRef ref, String fallbackText, + String handlerName, String handlerId, int handlerOrder, int recipeIndex, FilterExpr inputExpr, + FilterExpr outputExpr, int limit, boolean multi, boolean usageQuery) { + this.tagName = tagName; + this.idStr = idStr; + this.ref = ref; + this.fallbackText = fallbackText; + this.handlerName = handlerName; + this.handlerId = handlerId; + this.handlerOrder = handlerOrder; + this.recipeIndex = recipeIndex; + this.inputExpr = inputExpr; + this.outputExpr = outputExpr; + this.limit = limit; + this.multi = multi; + this.usageQuery = usageQuery; } } - private static String filterAttrName(@Nullable String attr) { - return attr == null || attr.isEmpty() ? "filter" : attr; - } - /** * {@code true} when {@code stack} satisfies {@code ref}: item identity match, plus meta equality * when {@code ref} isn't wildcard-meta, plus NBT equality when {@code ref.nbt()} is non-null. @@ -726,21 +477,6 @@ public static boolean evalArray(ItemStack[] stacks, FilterExpr expr) { return false; } - public static boolean recipeMatches(Object handler, int recipeIndex, FilterExpr inputExpr, FilterExpr outputExpr) { - return recipeMatches(handler, recipeIndex, inputExpr, outputExpr, REGISTRY_RECIPE_SLOT_ACCESS); - } - - public static boolean recipeMatches(Object handler, int recipeIndex, FilterExpr inputExpr, FilterExpr outputExpr, - RecipeSlotAccess recipeAccess) { - if (!outputExpr.isEmpty()) { - if (!evalRecipeResultSlot(recipeAccess.readResultSlot(handler, recipeIndex), outputExpr)) return false; - } - if (!inputExpr.isEmpty()) { - if (!evalRecipeSlots(recipeAccess.readIngredientSlots(handler, recipeIndex), inputExpr)) return false; - } - return true; - } - public static boolean recipeMatches(Object handler, int recipeIndex, FilterExpr inputExpr, FilterExpr outputExpr, HandlerRecipeAccess recipeAccess) { if (!outputExpr.isEmpty()) { @@ -769,4 +505,104 @@ public static boolean vanillaEntryMatches(RecipeLookup.Entry e, FilterExpr input if (!inputExpr.isEmpty() && !evalArray(e.input3x3, inputExpr)) return false; return true; } + + /** + * Parse an {@code input} / {@code output} attribute supporting OR (',') + AND ('&') + NOT ('!'). + * Malformed tokens emit a compile error and are skipped; a group with no surviving terms is + * dropped, and if every group is dropped the result is {@link FilterExpr#EMPTY} (i.e. "no + * filter", which is safer than "always fail"). + */ + public static FilterExpr parseFilterExpr(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el, + String attr, String defaultNs) { + String raw = trimToNull(MdxAttrs.getString(compiler, parent, el, attr, null)); + return parseFilterExpr(raw, attr, defaultNs, new Consumer() { + + @Override + public void accept(String message) { + parent.appendError(compiler, message, el); + } + }); + } + + public static FilterExpr parseFilterExpr(@Nullable String raw, String defaultNs) { + return parseFilterExpr(raw, null, defaultNs, null); + } + + private static FilterExpr parseFilterExpr(@Nullable String raw, @Nullable String attr, String defaultNs, + @Nullable Consumer errorSink) { + if (raw == null) return FilterExpr.EMPTY; + List> groups = new ArrayList<>(); + int rawLength = raw.length(); + int orStart = 0; + while (orStart <= rawLength) { + int orEnd = raw.indexOf(',', orStart); + if (orEnd < 0) { + orEnd = rawLength; + } + String orTrim = raw.substring(orStart, orEnd) + .trim(); + if (!orTrim.isEmpty()) { + List andTerms = new ArrayList<>(); + parseFilterTerms(orTrim, attr, defaultNs, errorSink, andTerms); + if (!andTerms.isEmpty()) groups.add(andTerms); + } + if (orEnd == rawLength) { + break; + } + orStart = orEnd + 1; + } + return groups.isEmpty() ? FilterExpr.EMPTY : new FilterExpr(groups); + } + + private static void parseFilterTerms(String orTrim, @Nullable String attr, String defaultNs, + @Nullable Consumer errorSink, List andTerms) { + int andLength = orTrim.length(); + int andStart = 0; + while (andStart <= andLength) { + int andEnd = orTrim.indexOf('&', andStart); + if (andEnd < 0) { + andEnd = andLength; + } + parseFilterTerm( + orTrim.substring(andStart, andEnd) + .trim(), + attr, + defaultNs, + errorSink, + andTerms); + if (andEnd == andLength) { + break; + } + andStart = andEnd + 1; + } + } + + private static void parseFilterTerm(String token, @Nullable String attr, String defaultNs, + @Nullable Consumer errorSink, List andTerms) { + if (token.isEmpty()) return; + boolean negated = false; + if (token.startsWith("!")) { + negated = true; + token = token.substring(1) + .trim(); + if (token.isEmpty()) { + if (errorSink != null) { + errorSink.accept("Empty " + filterAttrName(attr) + " negation token '!' has no id"); + } + return; + } + } + try { + IdUtils.ParsedItemRef p = IdUtils.parseItemRef(token, defaultNs); + if (p != null) andTerms.add(new FilterTerm(p, negated)); + } catch (IllegalArgumentException e) { + if (errorSink != null) { + errorSink.accept("Malformed " + filterAttrName(attr) + " filter '" + token + "': " + e.getMessage()); + } + } + } + + private static String filterAttrName(@Nullable String attr) { + return attr == null || attr.isEmpty() ? "filter" : attr; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SoundLinkCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SoundLinkCompiler.java index c82aa116..b0881e60 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SoundLinkCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SoundLinkCompiler.java @@ -26,8 +26,8 @@ protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElemen return; } var link = new LytFlowLink(); - link.setClickSoundSpec(sound); - link.setClickCallback(uiHost -> {}); + link.setStyleClass("SoundLink"); + link.setData("soundSpec", sound); compiler.compileInlineFragment(el.children(), link); parent.append(link); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StrongCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StrongCompiler.java new file mode 100644 index 00000000..5c5ac936 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StrongCompiler.java @@ -0,0 +1,25 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.flow.LytFlowParent; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; + +public class StrongCompiler extends FlowTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("strong"); + } + + @Override + protected void compile(PageCompiler compiler, LytFlowParent parent, MdxJsxElementFields el) { + var span = new LytFlowSpan(); + span.modifyStyle(style -> style.bold(true)); + compiler.compileFlowContext(el.children(), span); + parent.append(span); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StructureViewCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StructureViewCompiler.java index 09753679..4fbe9a13 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StructureViewCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/StructureViewCompiler.java @@ -1,15 +1,13 @@ package com.hfstudio.guidenh.guide.compiler.tags; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Set; -import net.minecraft.block.Block; -import net.minecraft.item.Item; -import net.minecraft.item.ItemStack; - import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; -import com.hfstudio.guidenh.guide.document.block.LytStructureView; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; import com.hfstudio.guidenh.libs.mdast.model.MdAstLiteral; @@ -33,11 +31,8 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { - var view = new LytStructureView(); - int width = MdxAttrs.getInt(compiler, parent, el, "width", 192); int height = MdxAttrs.getInt(compiler, parent, el, "height", 144); - view.setViewSize(width, height); StringBuilder text = new StringBuilder(); for (var child : el.children()) { @@ -47,13 +42,15 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl } } - parseLines(compiler, parent, view, text, el); + List entries = new ArrayList<>(); + parseLines(compiler, parent, entries, text, el); - parent.append(view); + StructurePlaceholder placeholder = new StructurePlaceholder(width, height, entries); + parent.append(placeholder); } - public static void parseLine(PageCompiler compiler, LytBlockContainer parent, LytStructureView view, String line, - MdxJsxElementFields el) { + private static void parseLine(PageCompiler compiler, LytBlockContainer parent, List entries, + String line, MdxJsxElementFields el) { String[] parts = firstTokens(line, 4); if (parts == null) { parent.appendError(compiler, "Structure entry needs ' ': " + line, el); @@ -71,27 +68,10 @@ public static void parseLine(PageCompiler compiler, LytBlockContainer parent, Ly } String idSpec = parts[3]; - int meta = 0; - int firstColon = idSpec.indexOf(':'); - int lastColon = idSpec.lastIndexOf(':'); - String resourceId = idSpec; - if (firstColon != lastColon) { - try { - meta = Integer.parseInt(idSpec.substring(lastColon + 1)); - resourceId = idSpec.substring(0, lastColon); - } catch (NumberFormatException ignored) {} - } - - ItemStack stack = resolveStack(resourceId, meta); - if (stack == null) { - parent.appendError(compiler, "Unknown block/item: " + idSpec, el); - return; - } - - view.addBlock(x, y, z, stack); + entries.add(new StructureEntry(x, y, z, idSpec)); } - private static void parseLines(PageCompiler compiler, LytBlockContainer parent, LytStructureView view, + private static void parseLines(PageCompiler compiler, LytBlockContainer parent, List entries, StringBuilder text, MdxJsxElementFields el) { int lineStart = 0; for (int i = 0; i <= text.length(); i++) { @@ -101,7 +81,7 @@ private static void parseLines(PageCompiler compiler, LytBlockContainer parent, parseRawLine( compiler, parent, - view, + entries, text.substring(lineStart, i) .trim(), el); @@ -112,12 +92,12 @@ private static void parseLines(PageCompiler compiler, LytBlockContainer parent, } } - private static void parseRawLine(PageCompiler compiler, LytBlockContainer parent, LytStructureView view, + private static void parseRawLine(PageCompiler compiler, LytBlockContainer parent, List entries, String line, MdxJsxElementFields el) { if (line.isEmpty() || line.startsWith("#")) { return; } - parseLine(compiler, parent, view, line, el); + parseLine(compiler, parent, entries, line, el); } private static String[] firstTokens(String line, int count) { @@ -151,15 +131,34 @@ private static int findWhitespace(String line, int offset) { return current; } - public static ItemStack resolveStack(String resourceId, int meta) { - var item = (Item) Item.itemRegistry.getObject(resourceId); - if (item != null) { - return new ItemStack(item, 1, meta); + public static class StructureEntry { + + public final int x; + public final int y; + public final int z; + public final String idSpec; + + public StructureEntry(int x, int y, int z, String idSpec) { + this.x = x; + this.y = y; + this.z = z; + this.idSpec = idSpec; } - var block = (Block) Block.blockRegistry.getObject(resourceId); - if (block != null) { - return new ItemStack(block, 1, meta); + } + + public static class StructurePlaceholder extends LytParagraph { + + public final int width; + public final int height; + public final List entries; + + public StructurePlaceholder(int width, int height, List entries) { + this.width = width; + this.height = height; + this.entries = entries; + setStyleClass("Structure"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); + appendText("[Structure]"); } - return null; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubPagesCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubPagesCompiler.java index 993eb483..58da6d5b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubPagesCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/SubPagesCompiler.java @@ -1,29 +1,15 @@ package com.hfstudio.guidenh.guide.compiler.tags; -import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; -import java.util.List; import java.util.Set; -import net.minecraft.util.ResourceLocation; - -import com.hfstudio.guidenh.guide.PageAnchor; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; -import com.hfstudio.guidenh.guide.document.block.LytList; -import com.hfstudio.guidenh.guide.document.block.LytListItem; import com.hfstudio.guidenh.guide.document.block.LytParagraph; -import com.hfstudio.guidenh.guide.document.flow.LytFlowLink; -import com.hfstudio.guidenh.guide.internal.GuideRegistry; -import com.hfstudio.guidenh.guide.navigation.NavigationNode; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class SubPagesCompiler extends BlockTagCompiler { - public static final Comparator ALPHABETICAL_COMPARATOR = Comparator - .comparing(NavigationNode::title); - @Override public Set getTagNames() { return Collections.singleton("SubPages"); @@ -32,53 +18,36 @@ public Set getTagNames() { @Override protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { var pageIdStr = el.getAttributeString("id", null); - var alphabetical = MdxAttrs.getBoolean(compiler, parent, el, "alphabetical", false); - - var navigationTree = GuideRegistry.getMergedNavigationTree(); - - List subNodes; - if ("".equals(pageIdStr)) { - subNodes = navigationTree.getRootNodes(); - } else { - ResourceLocation pageId; + if (pageIdStr != null) { try { - pageId = pageIdStr == null ? compiler.getPageId() : compiler.resolveId(pageIdStr); + pageIdStr = compiler.resolveId(pageIdStr) + .toString(); } catch (Exception e) { - parent.appendError(compiler, "Invalid id", el); - return; - } - - var node = navigationTree.getNodeById(pageId); - if (node == null) { - parent.appendError(compiler, "Couldn't find page " + pageId + " in the navigation tree", el); + parent.appendError(compiler, "Invalid id: " + pageIdStr, el); return; } - - subNodes = node.children(); - } - - if (alphabetical) { - subNodes = new ArrayList<>(subNodes); - subNodes.sort(ALPHABETICAL_COMPARATOR); } + var alphabetical = MdxAttrs.getBoolean(compiler, parent, el, "alphabetical", false); + var currentPageId = compiler.getPageId() + .toString(); - var list = new LytList(false, 0); - for (var childNode : subNodes) { - if (!childNode.hasPage()) { - continue; - } + SubPagesPlaceholder placeholder = new SubPagesPlaceholder(pageIdStr, alphabetical, currentPageId); + parent.append(placeholder); + } - var listItem = new LytListItem(); - var listItemPar = new LytParagraph(); + public static class SubPagesPlaceholder extends LytParagraph { - var link = new LytFlowLink(); - link.setGuideLink(childNode.guideId(), PageAnchor.page(childNode.pageId())); - link.appendText(childNode.title()); - listItemPar.append(link); + public final String pageIdStr; + public final boolean alphabetical; + public final String currentPageId; - listItem.append(listItemPar); - list.append(listItem); + public SubPagesPlaceholder(String pageIdStr, boolean alphabetical, String currentPageId) { + this.pageIdStr = pageIdStr; + this.alphabetical = alphabetical; + this.currentPageId = currentPageId; + setStyleClass("SubPages"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); + appendText("[SubPages]"); } - parent.append(list); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java new file mode 100644 index 00000000..2b47437a --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TableCompiler.java @@ -0,0 +1,89 @@ +package com.hfstudio.guidenh.guide.compiler.tags; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.table.LytTable; +import com.hfstudio.guidenh.guide.document.block.table.LytTableCell; +import com.hfstudio.guidenh.guide.document.block.table.LytTableRow; +import com.hfstudio.guidenh.guide.style.TextAlignment; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxFlowElement; + +public class TableCompiler extends BlockTagCompiler { + + @Override + public Set getTagNames() { + return Collections.singleton("table"); + } + + @Override + protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxElementFields el) { + LytTable table = new LytTable(); + table.setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + + // Parse align attribute back to list + String alignStr = el.getAttributeString("align", ""); + + boolean firstRow = true; + int rowIndex = 0; + for (var child : el.children()) { + // Skip kramdown {: widths=... } meta lines + if (child instanceof MdxJsxFlowElement meta && "table-meta".equals(meta.name())) { + String content = meta.getAttributeString("content", ""); + if (!content.isEmpty()) { + List widths = CsvTableCompiler.parseWidthHints(extractKramdownExpression(content)); + var columns = table.getColumns(); + for (int wi = 0; wi < widths.size() && wi < columns.size(); wi++) { + columns.get(wi) + .setPreferredWidth(widths.get(wi)); + } + } + continue; + } + if (child instanceof MdxJsxFlowElement tr && "tr".equals(tr.name())) { + LytTableRow row = table.appendRow(); + if (firstRow) { + row.modifyStyle(style -> style.bold(true)); + firstRow = false; + } + + int cellIndex = 0; + for (var cellChild : tr.children()) { + if (cellChild instanceof MdxJsxFlowElement td && "td".equals(td.name())) { + LytTableCell cell = row.appendCell(); + + // Apply alignment from the parsed align list + if (!alignStr.isEmpty()) { + String[] parts = alignStr.split(","); + if (cellIndex < parts.length) { + switch (parts[cellIndex].trim()) { + case "center" -> cell.modifyStyle(style -> style.alignment(TextAlignment.CENTER)); + case "right" -> cell.modifyStyle(style -> style.alignment(TextAlignment.RIGHT)); + } + } + } + + compiler.compileTableCellContent(td.children(), cell); + cellIndex++; + } + } + rowIndex++; + } + } + parent.append(table); + } + + private static String extractKramdownExpression(String content) { + int start = content.indexOf('{'); + int end = content.lastIndexOf('}'); + if (start >= 0 && end > start) { + return content.substring(start + 1, end) + .trim(); + } + return ""; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TooltipTagCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TooltipTagCompiler.java index 4330ca7f..4f610aa9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TooltipTagCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/TooltipTagCompiler.java @@ -42,6 +42,7 @@ private void compileCommon(PageCompiler compiler, LytFlowParent parent, MdxJsxEl compiler.compileBlockContextInSourceContext(el.children(), contentBox); var span = new LytTooltipSpan(); + span.setStyleClass("Tooltip"); span.modifyStyle(style -> style.underlined(true)); span.appendText(label); span.setTooltip(new ContentTooltip(contentBox)); diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/chart/ChartChildParser.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/chart/ChartChildParser.java index a2579c63..04aee283 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/chart/ChartChildParser.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/chart/ChartChildParser.java @@ -3,9 +3,6 @@ import java.util.ArrayList; import java.util.List; -import net.minecraft.item.Item; -import net.minecraft.item.ItemStack; -import net.minecraft.nbt.NBTTagCompound; import net.minecraft.util.ResourceLocation; import com.hfstudio.guidenh.guide.compiler.IdUtils; @@ -16,7 +13,6 @@ import com.hfstudio.guidenh.guide.document.block.chart.ChartSeries; import com.hfstudio.guidenh.guide.document.block.chart.PieInsetSpec; import com.hfstudio.guidenh.guide.document.block.chart.PieSlice; -import com.hfstudio.guidenh.guide.render.GuidePageTexture; import com.hfstudio.guidenh.guide.scene.SceneTagCompiler; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; import com.hfstudio.guidenh.libs.mdast.model.MdAstAnyContent; @@ -267,29 +263,16 @@ private static ChartIcon parseItemStackIcon(PageCompiler compiler, LytErrorSink if (ref == null) { return null; } - Item item = (Item) Item.itemRegistry.getObject(ref.rawKey()); - if (item == null) { - errorSink.appendError(compiler, "Missing item for icon: " + ref.id(), el); - return null; - } - ItemStack stack = new ItemStack(item, 1, ref.concreteMeta()); - if (ref.nbt() != null) { - stack.stackTagCompound = (NBTTagCompound) ref.nbt() - .copy(); - } - return ChartIcon.ofItemStack(stack); + // Deferred resolution: store raw key + meta, resolve at render time + return ChartIcon.ofDeferredItem(ref.rawKey(), ref.concreteMeta()); } private static ChartIcon parseImageIcon(PageCompiler compiler, LytErrorSink errorSink, MdxJsxElementFields el, String src) { try { ResourceLocation imgId = IdUtils.resolveLink(src, compiler.getPageId()); - byte[] data = compiler.loadAsset(imgId); - if (data == null) { - errorSink.appendError(compiler, "Missing icon image: " + src, el); - return null; - } - return ChartIcon.ofImage(imgId, GuidePageTexture.load(imgId, data)); + // Deferred resolution: store resolved id, load image data at render time + return ChartIcon.ofDeferredImage(imgId); } catch (IllegalArgumentException e) { errorSink.appendError(compiler, "Invalid icon image " + src + ": " + e.getMessage(), el); return null; diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java index 84374cbd..87464f73 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/CategoryCompiler.java @@ -1,17 +1,18 @@ package com.hfstudio.guidenh.guide.compiler.tags.mediawiki; import java.util.Collections; -import java.util.List; import java.util.Set; +import net.minecraft.util.ResourceLocation; + import com.hfstudio.guidenh.guide.compiler.IndexingContext; import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.tags.BlockTagCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.indices.CategoryIndex; import com.hfstudio.guidenh.guide.internal.GuidebookText; -import com.hfstudio.guidenh.guide.mediawiki.MediaWikiListEntry; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiPageListBuilder; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; @@ -36,32 +37,56 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl return; } - var context = MediaWikiTagCompilerSupport.createListContext(guide, compiler.getIndex(CategoryIndex.class)); - List entries = MediaWikiPageListBuilder.buildCategoryMembers(context, categoryName.trim()); - parent.append( - MediaWikiTagCompilerSupport.createBlock( - entries, - MediaWikiTagCompilerSupport.readRows(el), - GuidebookText.MediaWikiNoPagesInCategory.text())); + var block = new CategoryPlaceholder( + categoryName.trim(), + MediaWikiTagCompilerSupport.readRows(el), + guide.getId()); + parent.append(block); } @Override public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) { String categoryName = el.getAttributeString("name", null); if (categoryName == null || categoryName.trim() - .isEmpty()) { - return; - } + .isEmpty()) return; + // Restore Phase 2: index resolved category member titles for full-text search var guide = MediaWikiTagCompilerSupport.resolveGuide(indexer); - if (guide == null) { - return; + if (guide != null) { + CategoryIndex catIndex = indexer.getIndex(CategoryIndex.class); + if (catIndex != null) { + var context = MediaWikiTagCompilerSupport.createListContext(guide, catIndex); + var entries = MediaWikiPageListBuilder.buildCategoryMembers(context, categoryName.trim()); + sink.appendText(el, categoryName.trim()); + sink.appendBreak(); + for (var entry : entries) { + if (entry.title() != null && !entry.title() + .isEmpty()) { + sink.appendText(el, entry.title()); + } + sink.appendBreak(); + } + return; + } } - - var context = MediaWikiTagCompilerSupport.createListContext(guide, indexer.getIndex(CategoryIndex.class)); - sink.appendText(el, categoryName); + // Fallback: index only the category name + sink.appendText(el, categoryName.trim()); sink.appendBreak(); - MediaWikiTagCompilerSupport - .indexEntries(sink, el, MediaWikiPageListBuilder.buildCategoryMembers(context, categoryName.trim())); + } + + public static class CategoryPlaceholder extends LytParagraph { + + public final String name; + public final int rows; + public final ResourceLocation guideId; + + CategoryPlaceholder(String name, int rows, ResourceLocation guideId) { + this.name = name; + this.rows = rows; + this.guideId = guideId; + setStyleClass("Category"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); + appendText("[Category]"); + } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java index c9e96ae2..89bf4f24 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java +++ b/src/main/java/com/hfstudio/guidenh/guide/compiler/tags/mediawiki/SpecialCompiler.java @@ -3,22 +3,21 @@ import java.util.Collections; import java.util.Set; +import net.minecraft.util.ResourceLocation; + import com.hfstudio.guidenh.guide.compiler.IndexingContext; import com.hfstudio.guidenh.guide.compiler.IndexingSink; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.tags.BlockTagCompiler; import com.hfstudio.guidenh.guide.document.block.LytBlockContainer; +import com.hfstudio.guidenh.guide.document.block.LytParagraph; import com.hfstudio.guidenh.guide.indices.CategoryIndex; import com.hfstudio.guidenh.guide.internal.GuidebookText; -import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialPageQuery; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialPageResolver; -import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialPageResult; import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; public class SpecialCompiler extends BlockTagCompiler { - private final MediaWikiSpecialPageResolver resolver = new MediaWikiSpecialPageResolver(); - @Override public Set getTagNames() { return Collections.singleton("Special"); @@ -32,45 +31,71 @@ protected void compile(PageCompiler compiler, LytBlockContainer parent, MdxJsxEl parent.appendError(compiler, GuidebookText.MediaWikiMissingSpecialPageName.text(), el); return; } - String specialName = resolver.normalizeSupportedName(rawSpecialName); - if (specialName == null) { - parent.appendError(compiler, GuidebookText.MediaWikiUnsupportedSpecialPage.text(rawSpecialName), el); - return; - } var guide = MediaWikiTagCompilerSupport.resolveGuide(compiler, parent, el); if (guide == null) { return; } - var context = MediaWikiTagCompilerSupport.createListContext(guide, compiler.getIndex(CategoryIndex.class)); - MediaWikiSpecialPageQuery specialQuery = MediaWikiTagCompilerSupport.readSpecialQuery(el); - MediaWikiSpecialPageResult result = resolver - .resolve(context, specialName, specialQuery.withVisibleCount(Integer.MAX_VALUE)); - parent.append( - MediaWikiTagCompilerSupport - .createSpecialBlock(result, MediaWikiTagCompilerSupport.readRows(el), context, specialQuery, resolver)); + var block = new SpecialPlaceholder( + rawSpecialName.trim(), + MediaWikiTagCompilerSupport.readRows(el), + guide.getId(), + el.getAttributeString("page", null), + el.getAttributeString("prefix", null), + el.getAttributeString("language", null), + el.getAttributeString("query", null)); + parent.append(block); } @Override public void index(IndexingContext indexer, MdxJsxElementFields el, IndexingSink sink) { - String specialName = resolver.normalizeSupportedName(el.getAttributeString("name", null)); - if (specialName == null) { - return; - } + String specialName = el.getAttributeString("name", null); + if (specialName == null || specialName.trim() + .isEmpty()) return; + // Restore Phase 2: index resolved special page result entries for full-text search var guide = MediaWikiTagCompilerSupport.resolveGuide(indexer); - if (guide == null) { - return; + if (guide != null) { + CategoryIndex catIndex = indexer.getIndex(CategoryIndex.class); + if (catIndex != null) { + var context = MediaWikiTagCompilerSupport.createListContext(guide, catIndex); + var query = MediaWikiTagCompilerSupport.readSpecialQuery(el); + var resolver = new MediaWikiSpecialPageResolver(); + var result = resolver.resolve(context, specialName.trim(), query); + sink.appendText(el, specialName.trim()); + sink.appendBreak(); + MediaWikiTagCompilerSupport.indexSpecialResult(sink, el, result); + return; + } } - - var context = MediaWikiTagCompilerSupport.createListContext(guide, indexer.getIndex(CategoryIndex.class)); - sink.appendText(el, specialName); + // Fallback: index only the special page name + sink.appendText(el, specialName.trim()); sink.appendBreak(); - MediaWikiSpecialPageQuery specialQuery = MediaWikiTagCompilerSupport.readSpecialQuery(el); - MediaWikiTagCompilerSupport.indexSpecialResult( - sink, - el, - resolver.resolve(context, specialName, specialQuery.withVisibleCount(MediaWikiSpecialPageQuery.PAGE_SIZE))); + } + + public static class SpecialPlaceholder extends LytParagraph { + + public final String name; + public final int rows; + public final ResourceLocation guideId; + public final String page; + public final String prefix; + public final String language; + public final String query; + + SpecialPlaceholder(String name, int rows, ResourceLocation guideId, String page, String prefix, String language, + String query) { + this.name = name; + this.rows = rows; + this.guideId = guideId; + this.page = page; + this.prefix = prefix; + this.language = language; + this.query = query; + setStyleClass("Special"); + setStyle(LytParagraph.PLACEHOLDER_STYLE); + appendText("[Special]"); + } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java b/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java index 8ff9c872..e786e1a9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/DefaultStyles.java @@ -30,7 +30,8 @@ private DefaultStyles() {} WhiteSpaceMode.NORMAL, TextAlignment.LEFT, false, - null); + null, + false); public static final TextStyle BODY_TEXT = TextStyle.builder() .font(UNIFORM_FONT) diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/CodeHighlightFlowBuilder.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/CodeHighlightFlowBuilder.java new file mode 100644 index 00000000..040a0417 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/CodeHighlightFlowBuilder.java @@ -0,0 +1,61 @@ +package com.hfstudio.guidenh.guide.document.block; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; +import com.hfstudio.guidenh.guide.document.flow.LytFlowText; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightLine; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightResult; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightTheme; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightToken; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeTokenType; + +public class CodeHighlightFlowBuilder { + + private final Map colors; + + public CodeHighlightFlowBuilder(CodeHighlightTheme theme) { + colors = buildColors(theme); + } + + public List buildLines(CodeHighlightResult result) { + List lines = new ArrayList<>( + Math.max( + 1, + result.lines() + .size())); + for (CodeHighlightLine line : result.lines()) { + lines.add(buildLine(line)); + } + if (lines.isEmpty()) { + lines.add(new LytFlowSpan()); + } + return lines; + } + + private LytFlowSpan buildLine(CodeHighlightLine line) { + LytFlowSpan span = new LytFlowSpan(); + for (CodeHighlightToken token : line.tokens()) { + var node = LytFlowText.of(token.text()); + node.modifyStyle(style -> style.color(colorOf(token.type()))); + span.append(node); + } + return span; + } + + private ConstantColor colorOf(CodeTokenType type) { + return colors.getOrDefault(type, colors.get(CodeTokenType.PLAIN)); + } + + private Map buildColors(CodeHighlightTheme theme) { + Map result = new EnumMap<>(CodeTokenType.class); + for (CodeTokenType type : CodeTokenType.values()) { + result.put(type, new ConstantColor(theme.colorOf(type))); + } + return Map.copyOf(result); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java index 55719718..c5f797dd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytAlignedBlock.java @@ -27,7 +27,7 @@ */ public class LytAlignedBlock extends LytBlock { - private final LytBlock inner; + private LytBlock inner; private final ContentAlign align; /** @@ -48,6 +48,16 @@ public ContentAlign getAlign() { return align; } + @Override + public void replaceChild(LytNode oldChild, LytNode newChild) { + if (oldChild != inner || !(newChild instanceof LytBlock)) return; + inner.parent = null; + inner = (LytBlock) newChild; + inner.parent = this; + LytDocument doc = getDocument(); + if (doc != null) doc.invalidateLayout(); + } + @Override public List getChildren() { return List.of(inner); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java index c444740c..d70d77c8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytBox.java @@ -27,6 +27,7 @@ public abstract class LytBox extends LytBlock implements LytBlockContainer { @Override public void removeChild(LytNode node) { if (node instanceof LytBlock block && block.parent == this) { + if (isAttached()) LytDocument.notifyDetach(block); children.remove(block); block.parent = null; } @@ -39,6 +40,29 @@ public void append(LytBlock block) { } block.parent = this; children.add(block); + if (isAttached()) LytDocument.notifyAttach(block); + } + + @Override + public void replaceChild(LytNode oldChild, LytNode newChild) { + if (!(oldChild instanceof LytBlock)) return; + if (!(newChild instanceof LytBlock)) return; + LytBlock oldBlock = (LytBlock) oldChild; + LytBlock newBlock = (LytBlock) newChild; + int idx = children.indexOf(oldBlock); + if (idx < 0) return; + if (isAttached()) LytDocument.notifyDetach(oldBlock); + oldBlock.parent = null; + if (newBlock.parent != null) { + newBlock.parent.removeChild(newBlock); + } + newBlock.parent = this; + children.set(idx, newBlock); + if (isAttached()) LytDocument.notifyAttach(newBlock); + LytDocument doc = getDocument(); + if (doc != null) { + doc.invalidateLayout(); + } } public void clearContent() { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java index 921df903..82a6063e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytCodeBlock.java @@ -1,23 +1,22 @@ package com.hfstudio.guidenh.guide.document.block; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; +import java.util.Objects; import com.hfstudio.guidenh.guide.color.ConstantColor; -import com.hfstudio.guidenh.guide.color.SymbolicColor; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.flow.LytFlowSpan; -import com.hfstudio.guidenh.guide.document.flow.LytFlowText; import com.hfstudio.guidenh.guide.document.interaction.DocumentDragTarget; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorVerticalScrollbar; import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockLanguage; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightMode; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightResult; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightTheme; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlighter; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeTokenType; import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; @@ -26,17 +25,15 @@ public class LytCodeBlock extends LytVBox implements InteractiveElement, DocumentDragTarget { - private static final ConstantColor CODE_DEFAULT = new ConstantColor(0xFFD7DEE7); - private static final ConstantColor CODE_KEYWORD = new ConstantColor(0xFF7FD7FF); - private static final ConstantColor CODE_STRING = new ConstantColor(0xFF9BE28F); - private static final ConstantColor CODE_NUMBER = new ConstantColor(0xFFFFC774); - private static final ConstantColor CODE_COMMENT = new ConstantColor(0xFF7D8794); - private static final ConstantColor CODE_PUNCT = new ConstantColor(0xFFB7C0CD); + private static final CodeHighlightTheme CODE_THEME = CodeHighlightTheme.GITHUB_DARK_DEFAULT; + private static final CodeHighlighter CODE_HIGHLIGHTER = new CodeHighlighter(); + private static final CodeHighlightFlowBuilder FLOW_BUILDER = new CodeHighlightFlowBuilder(CODE_THEME); + private static final ConstantColor CODE_DEFAULT = new ConstantColor(CODE_THEME.colorOf(CodeTokenType.PLAIN)); + private static final ConstantColor CODE_BACKGROUND = new ConstantColor(CODE_THEME.backgroundArgb()); + private static final ConstantColor CODE_BORDER = new ConstantColor(CODE_THEME.borderArgb()); private static final int BODY_PADDING = 6; private static final int SCROLLBAR_WIDTH = 5; private static final int MIN_SCROLLBAR_THUMB = 14; - private static final Map> LANGUAGE_KEYWORDS = buildKeywordMap(); - private static final String[] ASCII_STRINGS = buildAsciiStrings(); private final LytCodeBlockToolbar toolbar = new LytCodeBlockToolbar(); private final LytParagraph body = new LytParagraph(); @@ -49,20 +46,25 @@ public class LytCodeBlock extends LytVBox implements InteractiveElement, Documen private int preferredBodyWidth; private int forcedBodyHeight; private int bodyContentHeight; + private int bodyViewportX; + private int bodyViewportY; + private int bodyViewportWidth; private int bodyViewportHeight; private int bodyScrollOffsetY; + private final SmoothFloatState visualBodyScrollOffsetY = new SmoothFloatState(); private boolean draggingBody; private int dragLastDocumentY; private boolean draggingScrollbar; private int scrollbarGrabOffsetY; private int lastBodyLineCount; + private CodeHighlightResult highlightResult = new CodeHighlightResult("text", CodeHighlightMode.PLAIN, List.of()); + private List highlightedLines = List.of(); public LytCodeBlock() { setPadding(6); setGap(4); setFullWidth(true); - setBackgroundColor(SymbolicColor.BLOCKQUOTE_BACKGROUND); - setBorder(new BorderStyle(SymbolicColor.TABLE_BORDER, 1)); + setBorder(new BorderStyle(CODE_BORDER, 1)); body.setMarginTop(0); body.setMarginBottom(0); @@ -84,12 +86,7 @@ public String getCodeText() { } public void setCodeText(String codeText) { - this.codeText = codeText != null ? codeText : ""; - this.normalizedCodeText = this.codeText.replace("\r\n", "\n") - .replace('\r', '\n'); - this.lastBodyLineCount = countBodyLines(); - toolbar.setCopyText(this.codeText); - rebuildBody(); + setCodeContent(languageFenceName, codeText); } public String getLanguageFenceName() { @@ -97,7 +94,23 @@ public String getLanguageFenceName() { } public void setLanguageFenceName(String languageFenceName) { - this.languageFenceName = languageFenceName != null ? languageFenceName : ""; + setCodeContent(languageFenceName, codeText); + } + + public void setCodeContent(String languageFenceName, String codeText) { + String resolvedFenceName = languageFenceName != null ? languageFenceName : ""; + String resolvedCodeText = codeText != null ? codeText : ""; + String resolvedNormalizedCodeText = GuideStringLines.normalizeLineEndings(resolvedCodeText); + boolean changed = !Objects.equals(this.languageFenceName, resolvedFenceName) + || !Objects.equals(this.codeText, resolvedCodeText); + this.languageFenceName = resolvedFenceName; + this.codeText = resolvedCodeText; + this.normalizedCodeText = resolvedNormalizedCodeText; + this.lastBodyLineCount = countBodyLines(resolvedNormalizedCodeText); + toolbar.setCopyText(this.codeText); + if (changed) { + rebuildBody(); + } } public String getLanguageDisplayName() { @@ -114,6 +127,10 @@ public String getDetectedLanguageId() { return detectedLanguageId; } + public CodeHighlightResult getHighlightResult() { + return highlightResult; + } + public void applyLanguage(CodeBlockLanguage language) { if (language == null) { detectedLanguageId = "text"; @@ -233,6 +250,7 @@ public boolean scroll(int documentX, int documentY, int wheelDelta) { protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { int safeWidth = preferredBodyWidth > 0 ? Math.max(1, Math.min(availableWidth, preferredBodyWidth)) : Math.max(1, availableWidth); + toolbar.setPreferredWidth(safeWidth); LytRect toolbarBounds = toolbar.layout(context, x, y, safeWidth); int bodyY = toolbarBounds.bottom() + getGap(); @@ -248,26 +266,29 @@ protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int avai } bodyViewportHeight = forcedBodyHeight > 0 ? forcedBodyHeight : bodyContentHeight; + bodyViewportX = x; + bodyViewportY = bodyY; + bodyViewportWidth = bodyAvailableWidth; setBodyScrollOffset(bodyScrollOffsetY); + snapVisualScrollToTarget(); return new LytRect(x, y, safeWidth, toolbarBounds.height() + getGap() + bodyViewportHeight); } @Override public void render(RenderContext context) { + updateVisualScroll(); LytRect ownBounds = getBounds(); if (ownBounds.isEmpty()) { return; } - if (getBackgroundColor() != null) { - context.fillRect(ownBounds, getBackgroundColor()); - } + context.fillRect(ownBounds, CODE_BACKGROUND); toolbar.render(context); LytRect bodyViewport = getBodyViewportBounds(); context.pushLocalScissor(bodyViewport); try { - body.render(context); + renderBodyWithVisualOffset(context); } finally { context.popScissor(); } @@ -283,23 +304,25 @@ private void syncToolbar() { } private void rebuildBody() { + highlightResult = CODE_HIGHLIGHTER.highlight(languageFenceName, normalizedCodeText); + detectedLanguageId = highlightResult.languageId(); + highlightedLines = FLOW_BUILDER.buildLines(highlightResult); body.clearContent(); - List lines = highlightLines(); - for (int i = 0; i < lines.size(); i++) { - body.append(lines.get(i)); - if (i < lines.size() - 1) { + for (int i = 0; i < highlightedLines.size(); i++) { + body.append(highlightedLines.get(i)); + if (i < highlightedLines.size() - 1) { body.appendBreak(); } } } - private int countBodyLines() { - if (normalizedCodeText.isEmpty()) { + private int countBodyLines(String text) { + if (text.isEmpty()) { return 1; } int lines = 1; - for (int i = 0; i < normalizedCodeText.length(); i++) { - if (normalizedCodeText.charAt(i) == '\n') { + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '\n') { lines++; } } @@ -318,32 +341,25 @@ private void renderScrollbar(RenderContext context) { if (track.isEmpty()) { return; } - context.fillRect(track, 0x30242B33); + context.fillRect(track, CODE_THEME.scrollbarTrackArgb()); LytRect thumb = getScrollbarThumbBounds(); if (!thumb.isEmpty()) { - context.fillRect(thumb, draggingScrollbar ? 0xFFCDD6E1 : 0xA0AAB5C2); + context.fillRect( + thumb, + draggingScrollbar ? CODE_THEME.scrollbarThumbActiveArgb() : CODE_THEME.scrollbarThumbArgb()); } } private LytRect getBodyViewportBounds() { - LytRect own = getBounds(); - LytRect toolbarBounds = toolbar.getBounds(); - int viewportY = toolbarBounds.bottom() + getGap(); - int viewportHeight = Math.max(0, bodyViewportHeight); - int viewportWidth = own.width(); - if (getMaxBodyScroll() > 0) { - viewportWidth = Math.max(1, viewportWidth - SCROLLBAR_WIDTH - 4); - } - return new LytRect(own.x(), viewportY, viewportWidth, viewportHeight); + return new LytRect(bodyViewportX, bodyViewportY, bodyViewportWidth, Math.max(0, bodyViewportHeight)); } private LytRect getScrollbarTrackBounds() { if (getMaxBodyScroll() <= 0) { return LytRect.empty(); } - LytRect own = getBounds(); LytRect viewport = getBodyViewportBounds(); - int x = own.right() - SCROLLBAR_WIDTH - 1; + int x = viewport.right() + 4; return new LytRect(x, viewport.y(), SCROLLBAR_WIDTH, viewport.height()); } @@ -359,7 +375,7 @@ private LytRect getScrollbarThumbBounds() { int thumbTrack = Math.max(1, track.height() - thumbHeight); int thumbY = track.y(); if (maxScroll > 0) { - thumbY += (int) ((long) thumbTrack * bodyScrollOffsetY / maxScroll); + thumbY += (int) ((long) thumbTrack * visualBodyScrollOffsetY.rounded() / maxScroll); } return new LytRect(track.x(), thumbY, track.width(), thumbHeight); } @@ -388,6 +404,20 @@ private void updateBodyPosition() { } } + private void renderBodyWithVisualOffset(RenderContext context) { + int renderDeltaY = bodyScrollOffsetY - visualBodyScrollOffsetY.rounded(); + if (renderDeltaY == 0) { + body.render(context); + return; + } + body.moveLayoutPos(0, renderDeltaY); + try { + body.render(context); + } finally { + body.moveLayoutPos(0, -renderDeltaY); + } + } + private void updateScrollFromMouseY(int mouseY) { LytRect track = getScrollbarTrackBounds(); LytRect thumb = getScrollbarThumbBounds(); @@ -402,276 +432,12 @@ private void updateScrollFromMouseY(int mouseY) { setBodyScrollOffset((int) ((long) (thumbTop - track.y()) * maxScroll / thumbTrack)); } - private List highlightLines() { - List lines = GuideStringLines.splitLines(normalizedCodeText); - String lowerLanguage = detectedLanguageId.toLowerCase(Locale.ROOT); - List result = new ArrayList<>(lines.size()); - for (String line : lines) { - result.add(highlightLine(line, lowerLanguage)); - } - if (lines.isEmpty()) { - result.add(new LytFlowSpan()); - } - return result; - } - - private LytFlowSpan highlightLine(String line, String lowerLanguage) { - LytFlowSpan root = new LytFlowSpan(); - if (line.isEmpty()) { - root.append(LytFlowText.of("")); - return root; - } - - int index = 0; - while (index < line.length()) { - int commentStart = findCommentStart(line, index, lowerLanguage); - if (commentStart == index) { - appendStyled(root, line.substring(index), CODE_COMMENT); - break; - } - if (commentStart > index) { - index = appendTokens(root, line, index, commentStart, lowerLanguage); - continue; - } - index = appendTokens(root, line, index, line.length(), lowerLanguage); - } - return root; - } - - private int appendTokens(LytFlowSpan root, String line, int start, int end, String language) { - int index = start; - while (index < end) { - char current = line.charAt(index); - if (current == '"' || current == '\'') { - int close = findStringEnd(line, index + 1, current, end); - appendStyled(root, line.substring(index, close), CODE_STRING); - index = close; - continue; - } - if (Character.isDigit(current)) { - int close = index + 1; - while (close < end && (Character.isDigit(line.charAt(close)) || line.charAt(close) == '.')) { - close++; - } - appendStyled(root, line.substring(index, close), CODE_NUMBER); - index = close; - continue; - } - if (Character.isLetter(current) || current == '_' || current == '$') { - int close = index + 1; - while (close < end) { - char next = line.charAt(close); - if (!Character.isLetterOrDigit(next) && next != '_' && next != '$') { - break; - } - close++; - } - String token = line.substring(index, close); - appendStyled(root, token, isKeyword(token, language) ? CODE_KEYWORD : CODE_DEFAULT); - index = close; - continue; - } - if (!Character.isWhitespace(current)) { - appendStyled(root, singleChar(current), CODE_PUNCT); - index++; - continue; - } - int close = index + 1; - while (close < end && Character.isWhitespace(line.charAt(close))) { - close++; - } - appendStyled(root, line.substring(index, close), CODE_DEFAULT); - index = close; - } - return index; - } - - private int findCommentStart(String line, int start, String language) { - int result = -1; - if (supportsSlashComment(language)) { - result = minPositive(result, line.indexOf("//", start)); - } - if (supportsHashComment(language)) { - result = minPositive(result, line.indexOf('#', start)); - } - if (supportsDashDashComment(language)) { - result = minPositive(result, line.indexOf("--", start)); - } - if ("properties".equals(language)) { - result = minPositive(result, line.indexOf(';', start)); - } - return result; - } - - private int minPositive(int current, int next) { - if (next < 0) { - return current; - } - if (current < 0) { - return next; - } - return Math.min(current, next); - } - - private boolean supportsSlashComment(String language) { - return "java".equals(language) || "kotlin".equals(language) - || "scala".equals(language) - || "groovy".equals(language) - || "json".equals(language) - || "javascript".equals(language); - } - - private boolean supportsHashComment(String language) { - return "yaml".equals(language) || "bash".equals(language) - || "powershell".equals(language) - || "properties".equals(language) - || "mermaid".equals(language); - } - - private boolean supportsDashDashComment(String language) { - return "lua".equals(language); - } - - private int findStringEnd(String line, int start, char quote, int end) { - int index = start; - while (index < end) { - char current = line.charAt(index); - if (current == '\\') { - index += 2; - continue; - } - index++; - if (current == quote) { - break; - } - } - return Math.min(index, end); - } - - private boolean isKeyword(String token, String language) { - if ("markdown".equals(language)) { - return token.startsWith("#"); - } - Set keywords = LANGUAGE_KEYWORDS.get(language); - return keywords != null && keywords.contains(token); - } - - private static Map> buildKeywordMap() { - Map> m = new HashMap<>(); - m.put( - "java", - kwSet( - "public", - "private", - "protected", - "class", - "interface", - "enum", - "static", - "void", - "new", - "return", - "if", - "else", - "switch", - "case", - "for", - "while", - "try", - "catch", - "throws")); - m.put( - "kotlin", - kwSet( - "fun", - "val", - "var", - "class", - "object", - "when", - "is", - "in", - "return", - "if", - "else", - "data", - "sealed")); - m.put( - "scala", - kwSet( - "object", - "class", - "trait", - "case", - "def", - "val", - "var", - "extends", - "match", - "yield", - "given", - "using")); - m.put( - "lua", - kwSet( - "local", - "function", - "end", - "if", - "then", - "elseif", - "else", - "for", - "while", - "repeat", - "until", - "return", - "nil", - "true", - "false")); - m.put( - "groovy", - kwSet( - "def", - "class", - "interface", - "enum", - "return", - "if", - "else", - "switch", - "case", - "for", - "while", - "in", - "as")); - m.put("json", kwSet("true", "false", "null")); - m.put("yaml", kwSet("true", "false", "null", "yes", "no")); - m.put("bash", kwSet("if", "then", "else", "fi", "for", "do", "done", "case", "esac", "function")); - m.put("powershell", kwSet("function", "param", "if", "else", "foreach", "switch", "return")); - m.put("mermaid", kwSet("graph", "flowchart", "mindmap", "subgraph")); - return m; - } - - private static Set kwSet(String... words) { - return new HashSet<>(List.of(words)); - } - - private static String[] buildAsciiStrings() { - String[] arr = new String[128]; - for (int i = 0; i < 128; i++) { - arr[i] = String.valueOf((char) i); - } - return arr; - } - - private static String singleChar(char c) { - return c < 128 ? ASCII_STRINGS[c] : Character.toString(c); + private void snapVisualScrollToTarget() { + visualBodyScrollOffsetY.snapTo(bodyScrollOffsetY); } - private void appendStyled(LytFlowSpan root, String text, ConstantColor color) { - var node = LytFlowText.of(text); - node.modifyStyle(style -> style.color(color)); - root.append(node); + private void updateVisualScroll() { + visualBodyScrollOffsetY + .updateTowards(bodyScrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, bodyViewportHeight * 2f)); } } 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 b4ec386e..80450585 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 @@ -2,6 +2,7 @@ import java.util.Optional; +import com.hfstudio.guidenh.guide.color.ColorValue; import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.color.SymbolicColor; import com.hfstudio.guidenh.guide.document.LytPoint; @@ -11,36 +12,60 @@ import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.document.interaction.TextTooltip; import com.hfstudio.guidenh.guide.internal.GuidebookText; +import com.hfstudio.guidenh.guide.internal.markdown.highlight.CodeHighlightTheme; import com.hfstudio.guidenh.guide.internal.screen.GuideIconButton; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.GuiSprite; +import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; public class LytCodeBlockToolbar extends LytBox implements InteractiveElement { - private static final GuiSprite COPY_SPRITE = new GuiSprite(GuideIconButton.TEX, 0, 48, 16, 16, 64, 64); + private static final GuiSprite COPY_SPRITE = new GuiSprite( + GuideIconButton.TEX, + 0, + 48, + 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; + private static final ConstantColor DEFAULT_TOOLBAR_BACKGROUND = new ConstantColor( + CODE_THEME.toolbarBackgroundArgb()); + private static final ConstantColor DEFAULT_TOOLBAR_BORDER = new ConstantColor(CODE_THEME.borderArgb()); + private static final ConstantColor DEFAULT_TOOLBAR_TEXT = new ConstantColor(CODE_THEME.toolbarTextArgb()); private final LytParagraph languageLabel = new LytParagraph(); private final LytGuiSprite copyButton = new LytGuiSprite(COPY_SPRITE, new LytSize(16, 16)); + private ColorValue toolbarBackground = DEFAULT_TOOLBAR_BACKGROUND; + private ColorValue toolbarBorder = DEFAULT_TOOLBAR_BORDER; + private ColorValue toolbarText = DEFAULT_TOOLBAR_TEXT; + private String copyText = ""; private boolean copied; private long copiedUntilMillis; private int preferredWidth; + private boolean copyButtonVisible = true; public LytCodeBlockToolbar() { languageLabel.setMarginTop(0); languageLabel.setMarginBottom(0); languageLabel.modifyStyle( style -> style.bold(true) - .color(new ConstantColor(0xFFB8BEC9))); - copyButton.setColor(SymbolicColor.ICON_BUTTON_NORMAL); + .color(toolbarText)); + copyButton.setColor(toolbarText); + copyButton.setHoverColor(SymbolicColor.ICON_BUTTON_HOVER); append(languageLabel); append(copyButton); + setPaddingLeft(8); + setPaddingTop(4); + setPaddingRight(8); setPaddingBottom(4); - setBorderBottom(new BorderStyle(SymbolicColor.TABLE_BORDER, 1)); + setBorderBottom(new BorderStyle(toolbarBorder, 1)); } public void setLanguageDisplayName(String languageDisplayName) { @@ -57,21 +82,48 @@ public void setPreferredWidth(int preferredWidth) { this.preferredWidth = Math.max(0, preferredWidth); } + public void setCopyButtonVisible(boolean copyButtonVisible) { + this.copyButtonVisible = copyButtonVisible; + } + + public void setToolbarBackground(ColorValue toolbarBackground) { + this.toolbarBackground = toolbarBackground != null ? toolbarBackground : DEFAULT_TOOLBAR_BACKGROUND; + } + + public void setToolbarBorder(ColorValue toolbarBorder) { + this.toolbarBorder = toolbarBorder != null ? toolbarBorder : DEFAULT_TOOLBAR_BORDER; + setBorderBottom(new BorderStyle(this.toolbarBorder, getBorderBottom().width())); + } + + public void setToolbarText(ColorValue toolbarText) { + this.toolbarText = toolbarText != null ? toolbarText : DEFAULT_TOOLBAR_TEXT; + languageLabel.modifyStyle(style -> style.color(this.toolbarText)); + copyButton.setColor(this.toolbarText); + } + @Override protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int availableWidth) { int toolbarWidth = preferredWidth > 0 ? Math.min(availableWidth, preferredWidth) : availableWidth; - int labelWidth = Math.max(1, toolbarWidth - 16 - 8); + int labelWidth = Math.max(1, toolbarWidth - (copyButtonVisible ? 16 + 8 : 0)); LytRect labelBounds = languageLabel.layout(context, x, y, labelWidth); int buttonX = x + Math.max(0, toolbarWidth - 16); - LytRect buttonBounds = copyButton.layout(context, buttonX, y, 16); - int height = Math.max(labelBounds.height(), buttonBounds.height()); - languageLabel.setLayoutPos(new LytPoint(labelBounds.x(), y + (height - labelBounds.height()) / 2f)); - copyButton.setLayoutPos(new LytPoint(buttonX, y + (height - buttonBounds.height()) / 2f)); + LytRect buttonBounds = copyButtonVisible ? copyButton.layout(context, buttonX, y, 16) : LytRect.empty(); + int height = Math.max(16, Math.max(labelBounds.height(), copyButtonVisible ? buttonBounds.height() : 0)); + float labelY = y + (height - labelBounds.height()) / 2f + TEXT_CENTERING_OFFSET_Y; + languageLabel.setLayoutPos(new LytPoint(labelBounds.x(), labelY)); + if (copyButtonVisible) { + copyButton.setLayoutPos(new LytPoint(buttonX, y + (height - buttonBounds.height()) / 2f)); + } else { + copyButton.setLayoutPos(new LytPoint(x + toolbarWidth, y)); + } return new LytRect(x, y, toolbarWidth, height); } @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { + if (!copyButtonVisible) { + return false; + } LytRect bounds = copyButton.getBounds(); if (button != 0 || bounds == null || !bounds.contains(x, y)) { return false; @@ -85,6 +137,9 @@ public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolea @Override public Optional getTooltip(float x, float y) { + if (!copyButtonVisible) { + return Optional.empty(); + } LytRect bounds = copyButton.getBounds(); if (bounds != null && bounds.contains((int) x, (int) y)) { return Optional.of(new TextTooltip(getCopyTooltipText())); @@ -96,6 +151,21 @@ public void setPaddingBottom(int paddingBottom) { this.paddingBottom = paddingBottom; } + @Override + public void render(RenderContext context) { + context.fillRect(bounds, toolbarBackground); + languageLabel.render(context); + if (copyButtonVisible) { + copyButton.render(context); + } + if (getBorderTop().width() > 0 || getBorderLeft().width() > 0 + || getBorderRight().width() > 0 + || getBorderBottom().width() > 0) { + new BorderRenderer() + .render(context, bounds, getBorderTop(), getBorderLeft(), getBorderRight(), getBorderBottom()); + } + } + private void markCopied() { copied = true; copiedUntilMillis = System.currentTimeMillis() + COPY_TOOLTIP_RESET_DELAY_MILLIS; diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java new file mode 100644 index 00000000..6f0ab6ee --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytContentTabsBlock.java @@ -0,0 +1,339 @@ +package com.hfstudio.guidenh.guide.document.block; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.color.ColorValue; +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.color.SymbolicColor; +import com.hfstudio.guidenh.guide.compiler.PageCompiler; +import com.hfstudio.guidenh.guide.compiler.tags.ContentTabsSpec; +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; +import com.hfstudio.guidenh.guide.document.interaction.GuideTooltip; +import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; +import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.BorderStyle; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; +import com.hfstudio.guidenh.guide.style.TextAlignment; +import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; +import com.hfstudio.guidenh.guide.ui.GuideUiHost; + +public class LytContentTabsBlock extends LytBlock implements InteractiveElement { + + private static final int ACCENT_WIDTH = 3; + private static final int CONTAINER_PAD_X = 10; + private static final int CONTAINER_PAD_Y = 6; + private static final int HEADER_GAP_X = 10; + private static final int HEADER_GAP_Y = 5; + private static final int HEADER_PAD_X = 2; + private static final int HEADER_PAD_TOP = 1; + private static final int HEADER_PAD_BOTTOM = 5; + private static final int HEADER_RULE_THICKNESS = 1; + private static final int ACTIVE_RULE_THICKNESS = 2; + private static final int TITLE_GAP = 4; + private static final int BODY_GAP = 6; + private static final ConstantColor DEFAULT_ACCENT = new ConstantColor(0xFF7C8795); + private static final int HEADER_RULE_COLOR = 0x66586275; + private final List tabs = new ArrayList<>(); + private final List children = new ArrayList<>(); + private final ColorValue accentColor; + @Nullable + private final LytParagraph titleParagraph; + private int selectedIndex; + private LytRect titleBounds = LytRect.empty(); + private LytRect headerBounds = LytRect.empty(); + private LytRect contentBounds = LytRect.empty(); + private static final ResolvedTextStyle SELECTED_STYLE = new ResolvedTextStyle( + 1.0f, + false, + false, + false, + false, + false, + false, + false, + "", + new ConstantColor(0xFFF4F7FB), + WhiteSpaceMode.NORMAL, + TextAlignment.LEFT, + false, + null, + false); + private static final ResolvedTextStyle IDLE_STYLE = new ResolvedTextStyle( + 1.0f, + false, + false, + false, + false, + false, + false, + false, + "", + new ConstantColor(0xFFD5DCE7), + WhiteSpaceMode.NORMAL, + TextAlignment.LEFT, + false, + null, + false); + + public LytContentTabsBlock(@Nullable String title, @Nullable LytFlowContent icon, int selectedIndex, + @Nullable ColorValue accentColor, List entries) { + this.accentColor = accentColor != null ? accentColor : DEFAULT_ACCENT; + this.selectedIndex = Math.max(0, selectedIndex); + this.titleParagraph = buildTitleParagraph(title, icon); + if (titleParagraph != null) { + titleParagraph.parent = this; + children.add(titleParagraph); + } + for (ContentTabsSpec.TabEntry entry : entries) { + tabs.add(new TabState(entry.title(), entry.body())); + children.add(entry.body()); + entry.body().parent = this; + } + setMarginTop(PageCompiler.DEFAULT_ELEMENT_SPACING); + setMarginBottom(PageCompiler.DEFAULT_ELEMENT_SPACING); + setFullWidth(true); + setBorderLeft(new BorderStyle(this.accentColor, ACCENT_WIDTH)); + } + + @Override + public List getChildren() { + // Expose every tab body to tree visitors so search, anchors, resource export, + // scene collection, and mount-time traversal still see hidden tabs. + return children; + } + + @Override + protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { + if (tabs.isEmpty()) { + titleBounds = LytRect.empty(); + headerBounds = LytRect.empty(); + contentBounds = LytRect.empty(); + return new LytRect(x, y, 0, 0); + } + + selectedIndex = Math.clamp(selectedIndex, 0, tabs.size() - 1); + + int contentX = x + ACCENT_WIDTH + CONTAINER_PAD_X; + int contentY = y + CONTAINER_PAD_Y; + int contentWidth = Math.max(0, availableWidth - ACCENT_WIDTH - CONTAINER_PAD_X * 2); + int tabsY = contentY; + if (titleParagraph != null) { + titleBounds = titleParagraph.layout(context, contentX, contentY, contentWidth); + tabsY = titleBounds.bottom() + TITLE_GAP; + } else { + titleBounds = LytRect.empty(); + } + int cursorX = contentX; + int cursorY = tabsY; + int rowHeight = 0; + int headerBottom = tabsY; + + for (TabState tab : tabs) { + int tabWidth = tab.measureWidth(context); + int tabHeight = tab.measureHeight(context); + if (cursorX > contentX && cursorX + tabWidth > contentX + contentWidth) { + cursorX = contentX; + cursorY += rowHeight + HEADER_GAP_Y; + rowHeight = 0; + } + tab.bounds = new LytRect(cursorX, cursorY, tabWidth, tabHeight); + cursorX += tabWidth + HEADER_GAP_X; + rowHeight = Math.max(rowHeight, tabHeight); + headerBottom = Math.max(headerBottom, tab.bounds.bottom()); + } + + headerBounds = new LytRect(contentX, tabsY, contentWidth, Math.max(0, headerBottom - tabsY)); + int safeSelectedIndex = getSafeSelectedIndex(); + LytBlock activeBody = tabs.get(safeSelectedIndex).body; + LytRect bodyBounds = activeBody.layout(context, contentX, headerBounds.bottom() + BODY_GAP, contentWidth); + int contentRight = Math.max(Math.max(titleBounds.right(), headerBounds.right()), bodyBounds.right()); + int contentBottom = Math.max(headerBounds.bottom(), bodyBounds.bottom()); + contentBounds = new LytRect( + contentX, + contentY, + Math.max(0, contentRight - contentX), + Math.max(0, contentBottom - contentY)); + return new LytRect( + x, + y, + Math.max(availableWidth, ACCENT_WIDTH + CONTAINER_PAD_X * 2 + contentBounds.width()), + contentBounds.height() + CONTAINER_PAD_Y * 2); + } + + @Override + protected void onLayoutMoved(int deltaX, int deltaY) { + titleBounds = titleBounds.move(deltaX, deltaY); + headerBounds = headerBounds.move(deltaX, deltaY); + contentBounds = contentBounds.move(deltaX, deltaY); + if (titleParagraph != null) { + titleParagraph.moveLayoutPos(deltaX, deltaY); + } + for (TabState tab : tabs) { + tab.bounds = tab.bounds.move(deltaX, deltaY); + } + if (!tabs.isEmpty()) { + tabs.get(getSafeSelectedIndex()).body.moveLayoutPos(deltaX, deltaY); + } + } + + @Override + public void render(RenderContext context) { + if (tabs.isEmpty()) { + return; + } + int safeSelectedIndex = getSafeSelectedIndex(); + int accentArgb = context.resolveColor(accentColor); + context.fillRect(bounds, context.resolveColor(SymbolicColor.BLOCKQUOTE_BACKGROUND)); + context.fillRect(bounds.x(), bounds.y(), ACCENT_WIDTH, bounds.height(), accentArgb); + if (titleParagraph != null) { + titleParagraph.render(context); + } + float panelRuleY = headerBounds.bottom() + HEADER_RULE_THICKNESS * 0.5f; + context.drawLine( + headerBounds.x(), + panelRuleY, + headerBounds.right(), + panelRuleY, + HEADER_RULE_THICKNESS, + HEADER_RULE_COLOR); + for (int index = 0; index < tabs.size(); index++) { + TabState tab = tabs.get(index); + boolean selected = index == safeSelectedIndex; + context.drawText( + tab.title, + tab.bounds.x() + HEADER_PAD_X, + tab.bounds.y() + HEADER_PAD_TOP, + tab.style(selected)); + if (selected) { + float activeRuleY = tab.bounds.bottom() - ACTIVE_RULE_THICKNESS * 0.5f; + context.drawLine( + tab.bounds.x(), + activeRuleY, + tab.bounds.right(), + activeRuleY, + ACTIVE_RULE_THICKNESS, + accentArgb); + } + } + tabs.get(safeSelectedIndex).body.render(context); + } + + @Override + public @Nullable LytNode pickNode(int x, int y) { + if (!bounds.contains(x, y)) { + return null; + } + if (titleParagraph != null) { + LytNode titleNode = titleParagraph.pickNode(x, y); + if (titleNode != null) { + return titleNode; + } + } + for (TabState tab : tabs) { + if (tab.bounds.contains(x, y)) { + return this; + } + } + if (!tabs.isEmpty()) { + LytNode activeNode = tabs.get(getSafeSelectedIndex()).body.pickNode(x, y); + if (activeNode != null) { + return activeNode; + } + } + return this; + } + + @Override + public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { + if (tabs.isEmpty()) { + return false; + } + if (button == 0) { + for (int index = 0; index < tabs.size(); index++) { + if (tabs.get(index).bounds.contains(x, y)) { + if (selectedIndex != index) { + selectedIndex = index; + if (getDocument() != null) { + getDocument().invalidateLayout(); + } + } + return true; + } + } + } + LytBlock activeBody = tabs.get(getSafeSelectedIndex()).body; + return activeBody instanceof InteractiveElement interactive + && interactive.mouseClicked(screen, x, y, button, doubleClick); + } + + @Override + public Optional getTooltip(float x, float y) { + if (tabs.isEmpty()) { + return Optional.empty(); + } + LytBlock activeBody = tabs.get(getSafeSelectedIndex()).body; + return activeBody instanceof InteractiveElement interactive ? interactive.getTooltip(x, y) : Optional.empty(); + } + + @Nullable + private LytParagraph buildTitleParagraph(@Nullable String title, @Nullable LytFlowContent icon) { + boolean hasTitle = title != null && !title.trim() + .isEmpty(); + if (!hasTitle && icon == null) { + return null; + } + LytParagraph paragraph = new LytParagraph(); + paragraph.setMarginTop(0); + paragraph.setMarginBottom(0); + paragraph.modifyStyle( + style -> style.bold(true) + .color(accentColor)); + if (icon != null) { + paragraph.append(icon); + if (hasTitle) { + paragraph.appendText(" "); + } + } + if (hasTitle) { + paragraph.appendText(title.trim()); + } + return paragraph; + } + + private int getSafeSelectedIndex() { + if (tabs.isEmpty()) { + return 0; + } + return Math.clamp(selectedIndex, 0, tabs.size() - 1); + } + + private static class TabState { + + private final String title; + private final LytBlock body; + private LytRect bounds = LytRect.empty(); + + private TabState(String title, LytBlock body) { + this.title = title; + this.body = body; + } + + private int measureWidth(LayoutContext context) { + return context.getStringWidth(title, style(false)) + HEADER_PAD_X * 2; + } + + private int measureHeight(LayoutContext context) { + return context.getLineHeight(style(false)) + HEADER_PAD_TOP + HEADER_PAD_BOTTOM; + } + + private ResolvedTextStyle style(boolean selected) { + return selected ? SELECTED_STYLE : IDLE_STYLE; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java index a3b99dac..658b0800 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDetailsBlock.java @@ -8,39 +8,56 @@ import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.color.SymbolicColor; import com.hfstudio.guidenh.guide.document.LytRect; +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.internal.editor.gui.SceneEditorVerticalScrollbar; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.RenderContext; import com.hfstudio.guidenh.guide.style.BorderStyle; import com.hfstudio.guidenh.guide.ui.GuideUiHost; -public class LytDetailsBlock extends LytBlock implements InteractiveElement, LytBlockContainer { +public class LytDetailsBlock extends LytBlock implements InteractiveElement, LytBlockContainer, DocumentDragTarget { private static final ConstantColor SUMMARY_COLOR = new ConstantColor(0xFFE2E6ED); private static final String SUMMARY_OPEN_MARKER = "v"; private static final String SUMMARY_CLOSED_MARKER = ">"; private static final String DEFAULT_SUMMARY_TEXT = "Details"; + private static final int PADDING = 6; + private static final int GAP = 4; + private static final int BORDER_WIDTH = 1; + private static final int SCROLLBAR_WIDTH = 5; + private static final int SCROLLBAR_GAP = 4; + private static final int MIN_SCROLLBAR_THUMB = 14; + private static final int MIN_WHEEL_STEP = 16; + private static final BorderStyle DETAILS_BORDER = new BorderStyle(SymbolicColor.TABLE_BORDER, BORDER_WIDTH); - private final LytVBox root = new LytVBox(); private final LytHBox summaryRow = new LytHBox(); private final LytParagraph summaryMarker = new LytParagraph(); private final LytParagraph summaryContent = new LytParagraph(); private final LytVBox content = new LytVBox(); + private final BorderRenderer borderRenderer = new BorderRenderer(); + private final SmoothFloatState visualContentScrollOffsetY = new SmoothFloatState(); private boolean open; @Nullable private String fallbackSummaryText; + private int preferredWidth; + private int preferredContentHeight; + private int contentHeight; + private int contentViewportX; + private int contentViewportY; + private int contentViewportWidth; + private int contentViewportHeight; + private int contentScrollOffsetY; + private boolean draggingContent; + private int dragLastDocumentY; + private boolean draggingScrollbar; + private int scrollbarGrabOffsetY; public LytDetailsBlock() { - root.parent = this; - root.setPadding(6); - root.setGap(4); - root.setFullWidth(true); - root.setBackgroundColor(SymbolicColor.BLOCKQUOTE_BACKGROUND); - root.setBorder(new BorderStyle(SymbolicColor.TABLE_BORDER, 1)); - - summaryRow.parent = root; + summaryRow.parent = this; summaryRow.setGap(4); summaryRow.setWrap(false); summaryRow.setFullWidth(true); @@ -58,18 +75,33 @@ public LytDetailsBlock() { style -> style.bold(true) .color(SUMMARY_COLOR)); - content.parent = root; + content.parent = this; content.setGap(4); content.setFullWidth(true); summaryRow.append(summaryMarker); summaryRow.append(summaryContent); - root.append(summaryRow); - root.append(content); syncSummaryMarker(); syncContentVisibility(); } + public int getPreferredWidth() { + return preferredWidth; + } + + public void setPreferredWidth(int preferredWidth) { + this.preferredWidth = Math.max(0, preferredWidth); + setFullWidth(this.preferredWidth <= 0); + } + + public int getPreferredContentHeight() { + return preferredContentHeight; + } + + public void setPreferredContentHeight(int preferredContentHeight) { + this.preferredContentHeight = Math.max(0, preferredContentHeight); + } + public LytParagraph getSummaryBox() { return summaryContent; } @@ -116,11 +148,6 @@ private void syncSummaryFallback() { private void syncContentVisibility() { syncSummaryFallback(); - root.clearContent(); - root.append(summaryRow); - if (open) { - root.append(content); - } } @Override @@ -133,24 +160,83 @@ public void removeChild(LytNode node) { content.removeChild(node); } + @Override + public void replaceChild(LytNode oldChild, LytNode newChild) { + content.replaceChild(oldChild, newChild); + } + @Override public List getChildren() { - return root.getChildren(); + return open ? List.of(summaryRow, content) : List.of(summaryRow); } @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { - return root.layout(context, x, y, availableWidth); + int safeWidth = preferredWidth > 0 ? Math.max(1, Math.min(availableWidth, preferredWidth)) + : Math.max(1, availableWidth); + int innerX = x + PADDING + BORDER_WIDTH; + int innerY = y + PADDING + BORDER_WIDTH; + int innerWidth = Math.max(1, safeWidth - (PADDING + BORDER_WIDTH) * 2); + + LytRect summaryBounds = summaryRow.layout(context, innerX, innerY, innerWidth); + int totalHeight = PADDING + BORDER_WIDTH + summaryBounds.height() + PADDING + BORDER_WIDTH; + + contentHeight = 0; + contentViewportX = innerX; + contentViewportY = summaryBounds.bottom() + GAP; + contentViewportWidth = innerWidth; + contentViewportHeight = 0; + + if (open) { + LytRect measuredContent = content.layout(context, contentViewportX, contentViewportY, contentViewportWidth); + contentHeight = measuredContent.height(); + contentViewportHeight = preferredContentHeight > 0 ? preferredContentHeight : contentHeight; + if (preferredContentHeight > 0 && contentHeight > contentViewportHeight) { + contentViewportWidth = Math.max(1, innerWidth - SCROLLBAR_WIDTH - SCROLLBAR_GAP); + measuredContent = content.layout(context, contentViewportX, contentViewportY, contentViewportWidth); + contentHeight = measuredContent.height(); + } + contentViewportHeight = preferredContentHeight > 0 ? preferredContentHeight : contentHeight; + setContentScrollOffset(contentScrollOffsetY); + snapVisualScrollToTarget(); + totalHeight = PADDING + BORDER_WIDTH + + summaryBounds.height() + + GAP + + contentViewportHeight + + PADDING + + BORDER_WIDTH; + } else { + setContentScrollOffset(0); + snapVisualScrollToTarget(); + } + + return new LytRect(x, y, safeWidth, totalHeight); } @Override protected void onLayoutMoved(int deltaX, int deltaY) { - root.moveLayoutPos(deltaX, deltaY); + summaryRow.moveLayoutPos(deltaX, deltaY); + content.moveLayoutPos(deltaX, deltaY); + contentViewportX += deltaX; + contentViewportY += deltaY; } @Override public void render(RenderContext context) { - root.render(context); + updateVisualScroll(); + context.fillRect(bounds, SymbolicColor.BLOCKQUOTE_BACKGROUND); + summaryRow.render(context); + if (open) { + LytRect viewport = getContentViewportBounds(); + context.pushLocalScissor(viewport); + try { + renderContentWithVisualOffset(context); + } finally { + context.popScissor(); + } + renderScrollbar(context); + } + borderRenderer.render(context, bounds, DETAILS_BORDER, DETAILS_BORDER, DETAILS_BORDER, DETAILS_BORDER); } @Override @@ -167,8 +253,187 @@ public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolea return false; } + @Override + public boolean beginDrag(int documentX, int documentY, int button) { + if (!open || button != 0 || getMaxContentScroll() <= 0) { + return false; + } + if (getScrollbarTrackBounds().contains(documentX, documentY)) { + LytRect thumbBounds = getScrollbarThumbBounds(); + if (!thumbBounds.isEmpty() && thumbBounds.contains(documentX, documentY)) { + scrollbarGrabOffsetY = documentY - thumbBounds.y(); + } else { + scrollbarGrabOffsetY = thumbBounds.isEmpty() ? 0 : thumbBounds.height() / 2; + updateScrollFromMouseY(documentY); + } + draggingScrollbar = true; + draggingContent = false; + return true; + } + if (!getContentViewportBounds().contains(documentX, documentY)) { + return false; + } + draggingContent = true; + draggingScrollbar = false; + dragLastDocumentY = documentY; + return true; + } + + @Override + public void dragTo(int documentX, int documentY) { + if (draggingScrollbar) { + updateScrollFromMouseY(documentY); + return; + } + if (!draggingContent) { + return; + } + int deltaY = documentY - dragLastDocumentY; + dragLastDocumentY = documentY; + setContentScrollOffset(contentScrollOffsetY - deltaY); + } + + @Override + public void endDrag() { + draggingContent = false; + draggingScrollbar = false; + } + + @Override + public boolean scroll(int documentX, int documentY, int wheelDelta) { + if (!open || wheelDelta == 0 + || getMaxContentScroll() <= 0 + || !getContentViewportBounds().contains(documentX, documentY)) { + return false; + } + setContentScrollOffset(contentScrollOffsetY - Integer.signum(wheelDelta) * MIN_WHEEL_STEP); + return true; + } + + @Override + public @Nullable LytNode pickNode(int x, int y) { + if (!bounds.contains(x, y)) { + return null; + } + if (summaryRow.getBounds() != null && summaryRow.getBounds() + .contains(x, y)) { + LytNode node = summaryRow.pickNode(x, y); + return node != null ? node : this; + } + if (open && getScrollbarTrackBounds().contains(x, y)) { + return this; + } + if (open && getContentViewportBounds().contains(x, y)) { + LytNode node = content.pickNode(x, y); + return node != null ? node : this; + } + return this; + } + @Override public Optional getTooltip(float x, float y) { return Optional.empty(); } + + private void renderScrollbar(RenderContext context) { + LytRect trackBounds = getScrollbarTrackBounds(); + if (trackBounds.isEmpty()) { + return; + } + context.fillRect(trackBounds, 0x30242B33); + LytRect thumbBounds = getScrollbarThumbBounds(); + if (!thumbBounds.isEmpty()) { + context.fillRect(thumbBounds, draggingScrollbar ? 0xFFCDD6E1 : 0xA0AAB5C2); + } + } + + private LytRect getContentViewportBounds() { + return new LytRect(contentViewportX, contentViewportY, contentViewportWidth, contentViewportHeight); + } + + private LytRect getScrollbarTrackBounds() { + if (getMaxContentScroll() <= 0) { + return LytRect.empty(); + } + return new LytRect( + contentViewportX + contentViewportWidth + SCROLLBAR_GAP, + contentViewportY, + SCROLLBAR_WIDTH, + contentViewportHeight); + } + + private LytRect getScrollbarThumbBounds() { + LytRect track = getScrollbarTrackBounds(); + if (track.isEmpty()) { + return LytRect.empty(); + } + int thumbHeight = Math + .max(MIN_SCROLLBAR_THUMB, track.height() * track.height() / Math.max(track.height(), contentHeight)); + thumbHeight = Math.min(thumbHeight, track.height()); + int maxScroll = getMaxContentScroll(); + int thumbTrack = Math.max(1, track.height() - thumbHeight); + int thumbY = track.y(); + if (maxScroll > 0) { + thumbY += (int) ((long) thumbTrack * visualContentScrollOffsetY.rounded() / maxScroll); + } + return new LytRect(track.x(), thumbY, track.width(), thumbHeight); + } + + private int getMaxContentScroll() { + return Math.max(0, contentHeight - contentViewportHeight); + } + + private void setContentScrollOffset(int contentScrollOffsetY) { + this.contentScrollOffsetY = SceneEditorVerticalScrollbar.clamp(contentScrollOffsetY, 0, getMaxContentScroll()); + updateContentPosition(); + } + + private void updateContentPosition() { + if (!content.getBounds() + .isEmpty()) { + content.moveLayoutPos( + contentViewportX - content.getBounds() + .x(), + contentViewportY - contentScrollOffsetY + - content.getBounds() + .y()); + } + } + + private void renderContentWithVisualOffset(RenderContext context) { + int renderDeltaY = contentScrollOffsetY - visualContentScrollOffsetY.rounded(); + if (renderDeltaY == 0) { + content.render(context); + return; + } + content.moveLayoutPos(0, renderDeltaY); + try { + content.render(context); + } finally { + content.moveLayoutPos(0, -renderDeltaY); + } + } + + private void updateScrollFromMouseY(int mouseY) { + LytRect track = getScrollbarTrackBounds(); + LytRect thumb = getScrollbarThumbBounds(); + if (track.isEmpty() || thumb.isEmpty()) { + setContentScrollOffset(0); + return; + } + int thumbTrack = Math.max(1, track.height() - thumb.height()); + int thumbTop = SceneEditorVerticalScrollbar + .clamp(mouseY - scrollbarGrabOffsetY, track.y(), track.y() + thumbTrack); + int maxScroll = getMaxContentScroll(); + setContentScrollOffset((int) ((long) (thumbTop - track.y()) * maxScroll / thumbTrack)); + } + + private void snapVisualScrollToTarget() { + visualContentScrollOffsetY.snapTo(contentScrollOffsetY); + } + + private void updateVisualScroll() { + visualContentScrollOffsetY + .updateTowards(contentScrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, contentViewportHeight * 2f)); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java index 36197b68..f61c4fff 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocument.java @@ -33,6 +33,8 @@ public class LytDocument extends LytNode implements LytBlockContainer { @Nullable private DocumentInteractionSnapshot hoveredElement; + private boolean live; + // Cached list of blocks intersecting the last rendered viewport. Invalidated whenever the // block list mutates or the layout is rebuilt; kept across frames otherwise so scrolling at // a steady viewport position only pays the iteration cost once. @@ -84,6 +86,23 @@ public void append(LytBlock block) { invalidateLayout(); } + @Override + public void replaceChild(LytNode oldChild, LytNode newNode) { + if (oldChild instanceof LytBlock oldBlock) { + int idx = blocks.indexOf(oldBlock); + if (idx < 0) return; + oldBlock.parent = null; + if (newNode instanceof LytBlock newBlock) { + if (newBlock.parent != null) { + newBlock.parent.removeChild(newBlock); + } + newBlock.parent = this; + blocks.set(idx, newBlock); + } + invalidateLayout(); + } + } + public void clearContent() { for (var block : blocks) { block.parent = null; @@ -96,6 +115,35 @@ public boolean hasLayout() { return layout != null; } + public boolean isLive() { + return live; + } + + public void setLive(boolean live) { + if (this.live == live) return; + this.live = live; + cascadeLive(this, live); + } + + private static void cascadeLive(LytNode node, boolean live) { + if (live) { + node.onAttach(); + } else { + node.onDetach(); + } + for (var child : node.getChildren()) { + cascadeLive(child, live); + } + } + + static void notifyAttach(LytNode node) { + cascadeLive(node, true); + } + + static void notifyDetach(LytNode node) { + cascadeLive(node, false); + } + public void invalidateLayout() { layout = null; invalidateVisibleCache(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java index 3f77bd2c..446f5b7f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytDocumentFloat.java @@ -39,7 +39,7 @@ public class LytDocumentFloat extends LytBlock { private static final int FLOAT_GAP = 5; - private final LytBlock inner; + private LytBlock inner; private final boolean floatRight; /** @@ -56,6 +56,16 @@ public LytBlock getInner() { return inner; } + @Override + public void replaceChild(LytNode oldChild, LytNode newChild) { + if (oldChild != inner || !(newChild instanceof LytBlock)) return; + inner.parent = null; + inner = (LytBlock) newChild; + inner.parent = this; + LytDocument doc = getDocument(); + if (doc != null) doc.invalidateLayout(); + } + public boolean isFloatRight() { return floatRight; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java index d6507c79..2b2879f9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytGuiSprite.java @@ -6,6 +6,7 @@ import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.LytSize; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.interaction.InteractiveElement; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.GuiSprite; @@ -21,6 +22,11 @@ public class LytGuiSprite extends LytBlock implements InteractiveElement { private ColorValue color = ConstantColor.WHITE; + @Nullable + private ColorValue hoverColor; + + private boolean hovered; + private LytSize size = new LytSize(16, 16); public LytGuiSprite() {} @@ -47,6 +53,10 @@ public void setColor(ColorValue color) { this.color = color != null ? color : ConstantColor.WHITE; } + public void setHoverColor(@Nullable ColorValue hoverColor) { + this.hoverColor = hoverColor; + } + public LytSize getSize() { return size; } @@ -82,10 +92,20 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab @Override protected void onLayoutMoved(int deltaX, int deltaY) {} + @Override + public void onMouseEnter(@Nullable LytFlowContent hoveredContent) { + hovered = true; + } + + @Override + public void onMouseLeave() { + hovered = false; + } + @Override public void render(RenderContext context) { if (sprite != null) { - context.fillIcon(getBounds(), sprite, color); + context.fillIcon(getBounds(), sprite, hovered && hoverColor != null ? hoverColor : color); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java new file mode 100644 index 00000000..44339f0d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytImageBlock.java @@ -0,0 +1,87 @@ +package com.hfstudio.guidenh.guide.document.block; + +import java.util.ArrayList; +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +/** + * A placeholder block for images that will be materialized by a LytScript. + * The styleClass (e.g., "Img" or "FloatingImage") tells LytHost which script handles it. + */ +public class LytImageBlock extends LytParagraph { + + @Nullable + private String src; + @Nullable + private String alt; + @Nullable + private String title; + private int explicitWidth = -1; + private int explicitHeight = -1; + @Nullable + private String align; + private final List annotations = new ArrayList<>(); + + @Nullable + public String getSrc() { + return src; + } + + public void setSrc(@Nullable String src) { + this.src = src; + } + + @Nullable + public String getAlt() { + return alt; + } + + public void setAlt(@Nullable String alt) { + this.alt = alt; + } + + @Nullable + public String getTitle() { + return title; + } + + public void setTitle(@Nullable String title) { + this.title = title; + } + + public int getExplicitWidth() { + return explicitWidth; + } + + public void setExplicitWidth(int explicitWidth) { + this.explicitWidth = explicitWidth > 0 ? explicitWidth : -1; + } + + public int getExplicitHeight() { + return explicitHeight; + } + + public void setExplicitHeight(int explicitHeight) { + this.explicitHeight = explicitHeight > 0 ? explicitHeight : -1; + } + + @Nullable + public String getAlign() { + return align; + } + + public void setAlign(@Nullable String align) { + this.align = align; + } + + public void addAnnotation(ImageRegionAnnotation annotation) { + if (annotation != null) { + annotations.add(annotation); + } + } + + public List getAnnotations() { + return annotations; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java index 8c297202..ee059db3 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexBlock.java @@ -171,6 +171,13 @@ public int getOffsetY() { return offsetY; } + public LytRect getVisualBounds() { + if (bounds == null || bounds.isEmpty()) { + return LytRect.empty(); + } + return new LytRect(bounds.x() + offsetX, bounds.y() + renderYOffset, bounds.width(), bounds.height()); + } + @Nullable @Override public LytRect getBounds() { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java index c6866f36..6280155b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytLatexDisplayBlock.java @@ -147,6 +147,15 @@ public int getOffsetY() { return offsetY; } + public LytRect getVisualBounds() { + if (bounds == null || bounds.isEmpty() || formulaDisplayW <= 0 || formulaDisplayH <= 0) { + return bounds != null ? bounds : LytRect.empty(); + } + int centeredX = bounds.x() + (bounds.width() - formulaDisplayW) / 2; + int formulaY = bounds.y() + VERTICAL_MARGIN; + return new LytRect(centeredX + offsetX, formulaY + offsetY, formulaDisplayW, formulaDisplayH); + } + @Nullable @Override public LytRect getBounds() { 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 5cd18924..6a5cc277 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 @@ -34,6 +34,10 @@ public LytMermaidMindmap(MermaidMindmapDocument mindmap, String sourceText, Map< toolbar.setLanguageDisplayName("Mermaid"); toolbar.setCopyText(this.sourceText); + toolbar.setCopyButtonVisible(false); + toolbar.setToolbarBackground(LytMermaidMindmapCanvas.PANEL_BACKGROUND); + toolbar.setToolbarBorder(LytMermaidMindmapCanvas.PANEL_BORDER); + toolbar.setToolbarText(LytMermaidMindmapCanvas.NODE_TEXT_COLOR); append(toolbar); append(canvas); 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 09bdc8d2..38e23f2b 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 @@ -18,6 +18,7 @@ import com.hfstudio.guidenh.guide.color.ConstantColor; import com.hfstudio.guidenh.guide.color.LightDarkMode; import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; import com.hfstudio.guidenh.guide.document.interaction.DocumentDragTarget; import com.hfstudio.guidenh.guide.document.interaction.DocumentInteractionSnapshot; import com.hfstudio.guidenh.guide.document.interaction.FlowInteractionPath; @@ -27,9 +28,13 @@ import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapLayoutMode; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapNode; import com.hfstudio.guidenh.guide.internal.mermaid.MermaidMindmapNodeShape; +import com.hfstudio.guidenh.guide.internal.recipe.LytNeiRecipeBox; import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; +import com.hfstudio.guidenh.guide.render.GuiSprite; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; import com.hfstudio.guidenh.guide.style.TextAlignment; import com.hfstudio.guidenh.guide.style.WhiteSpaceMode; @@ -50,6 +55,11 @@ public class LytMermaidMindmapCanvas extends LytBlock implements DocumentDragTar private static final float ZOOM_STEP = 1.1f; private static final float MIN_ZOOM = 0.5f; private static final float MAX_ZOOM = 2.5f; + static final ConstantColor PANEL_BACKGROUND = new ConstantColor(0x1A0C1117); + static final ConstantColor PANEL_BORDER = new ConstantColor(0x66434C57); + static final ConstantColor ROOT_TEXT_COLOR = new ConstantColor(0xFFF1F6FB); + static final ConstantColor NODE_TEXT_COLOR = new ConstantColor(0xFFD7DEE7); + static final ConstantColor ICON_TEXT_COLOR = new ConstantColor(0xFFB8C2CF); private static final ResolvedTextStyle ROOT_TEXT_STYLE = new ResolvedTextStyle( 1f, @@ -61,11 +71,12 @@ public class LytMermaidMindmapCanvas extends LytBlock implements DocumentDragTar false, false, null, - new ConstantColor(0xFFF1F6FB), + ROOT_TEXT_COLOR, WhiteSpaceMode.NORMAL, TextAlignment.LEFT, false, - null); + null, + false); private static final ResolvedTextStyle NODE_TEXT_STYLE = new ResolvedTextStyle( 1f, false, @@ -76,11 +87,12 @@ public class LytMermaidMindmapCanvas extends LytBlock implements DocumentDragTar false, false, null, - new ConstantColor(0xFFD7DEE7), + NODE_TEXT_COLOR, WhiteSpaceMode.NORMAL, TextAlignment.LEFT, false, - null); + null, + false); private static final ResolvedTextStyle ICON_TEXT_STYLE = new ResolvedTextStyle( 0.85f, false, @@ -91,11 +103,12 @@ public class LytMermaidMindmapCanvas extends LytBlock implements DocumentDragTar false, false, null, - new ConstantColor(0xFFB8C2CF), + ICON_TEXT_COLOR, WhiteSpaceMode.NORMAL, TextAlignment.LEFT, false, - null); + null, + false); private final MermaidMindmapDocument mindmap; private final Map nodeContentBlocks; @@ -103,7 +116,10 @@ public class LytMermaidMindmapCanvas extends LytBlock implements DocumentDragTar private DiagramLayout layout; private int contentOffsetX; private int contentOffsetY; + private final SmoothFloatState visualContentOffsetX = new SmoothFloatState(); + private final SmoothFloatState visualContentOffsetY = new SmoothFloatState(); private float zoom = 1f; + private final SmoothFloatState visualZoom = new SmoothFloatState(); private int preferredWidth; private int preferredHeight; private float scaledStyleZoom = Float.NaN; @@ -114,6 +130,13 @@ public class LytMermaidMindmapCanvas extends LytBlock implements DocumentDragTar private boolean dragging; private int dragLastDocumentX; private int dragLastDocumentY; + private int lastPickDocX; + private int lastPickDocY; + private boolean lastPickValid; + @Nullable + private LytParagraph lastFlowHoverParagraph; + @Nullable + private LytFlowContent lastFlowHoverContent; public LytMermaidMindmapCanvas(MermaidMindmapDocument mindmap, Map nodeContentBlocks) { this.mindmap = mindmap; @@ -135,10 +158,12 @@ public void setPreferredSize(int width, int height) { @Override protected LytRect computeLayout(LayoutContext context, int x, int y, int availableWidth) { - int preferredViewportWidth = preferredWidth > 0 - ? ResponsiveVisualSizing.scaleWidth(preferredWidth, context.getVisualScale(), 1) - : 0; - int safeWidth = preferredViewportWidth > 0 ? Math.max(1, Math.min(preferredViewportWidth, availableWidth)) + DiagramLayout previousLayout = layout; + int previousContentOffsetX = contentOffsetX; + int previousContentOffsetY = contentOffsetY; + int previousViewportWidth = Math.max(1, bounds.width() - CANVAS_PADDING * 2); + int previousViewportHeight = Math.max(1, bounds.height() - CANVAS_PADDING * 2); + int safeWidth = preferredWidth > 0 ? Math.max(1, Math.min(preferredWidth, availableWidth)) : Math.max(1, availableWidth); layout = buildLayout(context, safeWidth); int desiredHeight = layout.diagramHeight() + CANVAS_PADDING * 2; @@ -147,7 +172,16 @@ protected LytRect computeLayout(LayoutContext context, int x, int y, int availab if (preferredHeight > 0 && safeWidth < resolvePreferredViewportWidth()) { viewportHeight = Math.max(viewportHeight, Math.min(MAX_HEIGHT, desiredHeight)); } - centerDiagram(safeWidth, viewportHeight); + int viewportWidth = Math.max(1, safeWidth - CANVAS_PADDING * 2); + int innerViewportHeight = Math.max(1, viewportHeight - CANVAS_PADDING * 2); + restoreViewportAfterLayout( + previousLayout, + previousContentOffsetX, + previousContentOffsetY, + previousViewportWidth, + previousViewportHeight, + viewportWidth, + innerViewportHeight); return new LytRect(x, y, safeWidth, viewportHeight); } @@ -159,23 +193,25 @@ public void render(RenderContext context) { if (layout == null) { return; } - ensureScaledStyles(); + updateVisualState(); + ensureScaledStyles(visualZoom.value()); - context.fillRect(bounds, 0x1A0C1117); - context.drawBorder(bounds, 0x66434C57, 1); + context.fillRect(bounds, PANEL_BACKGROUND); + context.drawBorder(bounds, context.resolveColor(PANEL_BORDER), 1); LytRect viewport = getInnerViewport(); - int baseX = viewport.x() + contentOffsetX + int baseX = viewport.x() + visualContentOffsetX.rounded() - Math.round( layout.contentBounds() - .x() * zoom); - int baseY = viewport.y() + contentOffsetY + .x() * visualZoom.value()); + int baseY = viewport.y() + visualContentOffsetY.rounded() - Math.round( layout.contentBounds() - .y() * zoom); + .y() * visualZoom.value()); context.pushLocalScissor(viewport); try { + refreshFlowHover(); renderConnectors(context, layout.root(), baseX, baseY); renderNodes(context, layout.root(), baseX, baseY); } finally { @@ -183,15 +219,26 @@ public void render(RenderContext context) { } } + @Override + public LytNode pickNode(int x, int y) { + if (!getBounds().contains(x, y)) return null; + lastPickDocX = x; + lastPickDocY = y; + lastPickValid = true; + NodeHit hit = pickNodeHit(x, y); + return hit != null ? hit.node() : this; + } + + @Override + public List getChildren() { + return new ArrayList<>(nodeContentBlocks.values()); + } + @Override public boolean beginDrag(int documentX, int documentY, int button) { - if (button != 0 || layout == null) { - return false; - } + if (button != 0 || layout == null) return false; LytRect viewport = getInnerViewport(); - if (!viewport.contains(documentX, documentY)) { - return false; - } + if (!viewport.contains(documentX, documentY)) return false; dragging = true; dragLastDocumentX = documentX; dragLastDocumentY = documentY; @@ -200,9 +247,7 @@ public boolean beginDrag(int documentX, int documentY, int button) { @Override public void dragTo(int documentX, int documentY) { - if (!dragging || layout == null) { - return; - } + if (!dragging || layout == null) return; int dx = documentX - dragLastDocumentX; int dy = documentY - dragLastDocumentY; dragLastDocumentX = documentX; @@ -219,9 +264,10 @@ public void endDrag() { @Override public boolean scroll(int documentX, int documentY, int wheelDelta) { - if (wheelDelta == 0 || layout == null || !getInnerViewport().contains(documentX, documentY)) { - return false; - } + if (wheelDelta == 0 || layout == null || !getInnerViewport().contains(documentX, documentY)) return false; + LytRect viewport = getInnerViewport(); + int previousOffsetX = contentOffsetX; + int previousOffsetY = contentOffsetY; float previousZoom = zoom; if (wheelDelta > 0) { zoom = Math.min(MAX_ZOOM, zoom * ZOOM_STEP); @@ -231,34 +277,37 @@ public boolean scroll(int documentX, int documentY, int wheelDelta) { if (Math.abs(previousZoom - zoom) < 0.0001f) { return false; } + float anchorX = layout.contentBounds() + .x() + (documentX - viewport.x() - previousOffsetX) / Math.max(previousZoom, 0.0001f); + float anchorY = layout.contentBounds() + .y() + (documentY - viewport.y() - previousOffsetY) / Math.max(previousZoom, 0.0001f); + contentOffsetX = Math.round( + (documentX - viewport.x()) - (anchorX - layout.contentBounds() + .x()) * zoom); + contentOffsetY = Math.round( + (documentY - viewport.y()) - (anchorY - layout.contentBounds() + .y()) * zoom); clampOffsets(); return true; } @Override public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolean doubleClick) { - if (layout == null || !getInnerViewport().contains(x, y)) { - return false; - } + if (layout == null || !getInnerViewport().contains(x, y)) return false; NodeHit hit = pickNodeHit(x, y); - if (hit == null) { - return false; - } + if (hit == null) return false; boolean handled = false; for (var content : hit.flowPath() .targets()) { - if (handled) { - break; - } if (content instanceof InteractiveElement interactiveElement) { handled = interactiveElement.mouseClicked(screen, hit.localX(), hit.localY(), button, doubleClick); + if (handled) return true; } } - if (!handled) { - for (LytNode current = hit.node(); current != null && !handled; current = current.getParent()) { - if (current instanceof InteractiveElement interactiveElement) { - handled = interactiveElement.mouseClicked(screen, hit.localX(), hit.localY(), button, doubleClick); - } + for (LytNode current = hit.node(); current != null && current != this + && !handled; current = current.getParent()) { + if (current instanceof InteractiveElement interactiveElement) { + handled = interactiveElement.mouseClicked(screen, hit.localX(), hit.localY(), button, doubleClick); } } return handled; @@ -266,28 +315,20 @@ public boolean mouseClicked(GuideUiHost screen, int x, int y, int button, boolea @Override public Optional getTooltip(float x, float y) { - if (layout == null || !getInnerViewport().contains((int) x, (int) y)) { - return Optional.empty(); - } + if (layout == null || !getInnerViewport().contains((int) x, (int) y)) return Optional.empty(); NodeHit hit = pickNodeHit((int) x, (int) y); - if (hit == null) { - return Optional.empty(); - } + if (hit == null) return Optional.empty(); for (var content : hit.flowPath() .targets()) { if (content instanceof InteractiveElement interactiveElement) { Optional tooltip = interactiveElement.getTooltip(hit.localX(), hit.localY()); - if (tooltip.isPresent()) { - return tooltip; - } + if (tooltip.isPresent()) return tooltip; } } - for (LytNode current = hit.node(); current != null; current = current.getParent()) { + for (LytNode current = hit.node(); current != null && current != this; current = current.getParent()) { if (current instanceof InteractiveElement interactiveElement) { Optional tooltip = interactiveElement.getTooltip(hit.localX(), hit.localY()); - if (tooltip.isPresent()) { - return tooltip; - } + if (tooltip.isPresent()) return tooltip; } } return Optional.empty(); @@ -365,7 +406,7 @@ private DiagramLayout buildLayout(LayoutContext context, int availableWidth) { } private DiagramLayout buildDiagramLayout(NodeLayout root) { - LytRect contentBounds = collectContentBounds(root); + LytRect contentBounds = collectRenderedBounds(root); return new DiagramLayout( root, Math.max(1, contentBounds.width()), @@ -374,10 +415,10 @@ private DiagramLayout buildDiagramLayout(NodeLayout root) { collectContentNodes(root, new ArrayList<>())); } - private LytRect collectContentBounds(NodeLayout node) { - LytRect bounds = new LytRect(node.x, node.y, node.width, node.height); + private LytRect collectRenderedBounds(NodeLayout node) { + LytRect bounds = resolveNodeVisualRect(node); for (NodeLayout child : node.children) { - bounds = LytRect.union(bounds, collectContentBounds(child)); + bounds = LytRect.union(bounds, collectRenderedBounds(child)); } return bounds; } @@ -424,8 +465,10 @@ private NodeLayout prepareLayout(LayoutContext context, MermaidMindmapNode node, int lineHeight = context.getLineHeight(style); textHeight = Math.max(1, lines.size()) * lineHeight; } else { - textWidth = contentLayout.width(); - textHeight = contentLayout.height(); + textWidth = contentLayout.visualBounds() + .width(); + textHeight = contentLayout.visualBounds() + .height(); } int badgeWidth = 0; @@ -473,8 +516,9 @@ private NodeLayout prepareLayout(LayoutContext context, MermaidMindmapNode node, } LayoutContext localContext = new LayoutContext(context).withVisualScale(context.getVisualScale()); int contentWidth = Math.clamp(maxNodeTextWidth + 60, 96, 240); - LytRect contentBounds = block.layout(localContext, 0, 0, contentWidth); - return new NodeContentLayout(block, contentBounds.width(), contentBounds.height()); + block.layout(localContext, 0, 0, contentWidth); + LytRect visualBounds = resolveBlockVisualBounds(block); + return new NodeContentLayout(block, visualBounds); } private void measureSideTree(NodeLayout node) { @@ -567,25 +611,26 @@ private void layoutTopDown(NodeLayout node, int x, int y) { } private void renderConnectors(RenderContext context, NodeLayout node, int baseX, int baseY) { + float activeZoom = visualZoom.value(); for (NodeLayout child : node.children) { if (mindmap.getLayoutMode() == MermaidMindmapLayoutMode.TIDY_TREE) { drawVerticalConnector( context, - scaled(baseX, node.centerX()), - scaled(baseY, node.bottom()), - scaled(baseX, child.centerX()), - scaled(baseY, child.y), + scaled(baseX, node.centerX(), activeZoom), + scaled(baseY, node.bottom(), activeZoom), + scaled(baseX, child.centerX(), activeZoom), + scaled(baseY, child.y, activeZoom), 0xFF5D6C7C); } else { boolean rightSide = child.centerX() >= node.centerX(); - int parentEdgeX = scaled(baseX, rightSide ? node.right() : node.x); - int childEdgeX = scaled(baseX, rightSide ? child.x : child.right()); + int parentEdgeX = scaled(baseX, rightSide ? node.right() : node.x, activeZoom); + int childEdgeX = scaled(baseX, rightSide ? child.x : child.right(), activeZoom); drawHorizontalConnector( context, parentEdgeX, - scaled(baseY, node.centerY()), + scaled(baseY, node.centerY(), activeZoom), childEdgeX, - scaled(baseY, child.centerY()), + scaled(baseY, child.centerY(), activeZoom), 0xFF5D6C7C); } renderConnectors(context, child, baseX, baseY); @@ -593,23 +638,25 @@ private void renderConnectors(RenderContext context, NodeLayout node, int baseX, } private void renderNodes(RenderContext context, NodeLayout node, int baseX, int baseY) { + float activeZoom = visualZoom.value(); LytRect rect = new LytRect( - scaled(baseX, node.x), - scaled(baseY, node.y), - Math.max(1, Math.round(node.width * zoom)), - Math.max(1, Math.round(node.height * zoom))); + scaled(baseX, node.x, activeZoom), + scaled(baseY, node.y, activeZoom), + Math.max(1, Math.round(node.width * activeZoom)), + Math.max(1, Math.round(node.height * activeZoom))); + LytRect boxRect = rect; NodeColors colors = resolveColors(node.node); - context.fillRect(rect, colors.background); - context.drawBorder(rect, colors.border, node.node.getShape() == MermaidMindmapNodeShape.BANG ? 2 : 1); - context.fillRect(new LytRect(rect.x(), rect.y(), 3, rect.height()), colors.accent); + context.fillRect(boxRect, colors.background); + context.drawBorder(boxRect, colors.border, node.node.getShape() == MermaidMindmapNodeShape.BANG ? 2 : 1); + context.fillRect(new LytRect(boxRect.x(), boxRect.y(), 3, boxRect.height()), colors.accent); ResolvedTextStyle style = node.depth == 0 ? scaledRootTextStyle : scaledNodeTextStyle; ResolvedTextStyle badgeStyle = scaledIconTextStyle; - int paddingX = Math.max(1, Math.round(NODE_PADDING_X * zoom)); - int paddingY = Math.max(1, Math.round(NODE_PADDING_Y * zoom)); - int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * zoom)); - int badgePaddingX = Math.max(2, Math.round(4 * zoom)); - int badgePaddingY = Math.max(1, Math.round(2 * zoom)); + int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); + int paddingY = Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)); + int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * activeZoom)); + int badgePaddingX = Math.max(2, Math.round(4 * activeZoom)); + int badgePaddingY = Math.max(1, Math.round(2 * activeZoom)); int textY = rect.y() + paddingY; if (node.showBadge && node.badgeText != null) { int badgeWidth = Math.max(1, context.getStringWidth(node.badgeText, badgeStyle) + badgePaddingX * 2); @@ -625,7 +672,7 @@ private void renderNodes(RenderContext context, NodeLayout node, int baseX, int } if (node.contentLayout != null) { - renderNodeContent(context, node, rect, paddingX, textY); + renderNodeContent(context, node, rect, paddingX, textY, activeZoom); } else { int lineHeight = context.getLineHeight(style); for (String line : node.lines) { @@ -641,57 +688,163 @@ private void renderNodes(RenderContext context, NodeLayout node, int baseX, int } } - private void renderNodeContent(RenderContext context, NodeLayout node, LytRect rect, int paddingX, int contentY) { + private void renderNodeContent(RenderContext context, NodeLayout node, LytRect rect, int paddingX, int contentY, + float activeZoom) { if (node.contentLayout == null) { return; } LytRect viewport = getInnerViewport(); - LytRect contentViewport = new LytRect( - rect.x() + paddingX, - contentY, - Math.max(1, rect.width() - paddingX * 2), - Math.max(1, rect.bottom() - contentY - Math.max(1, Math.round(NODE_PADDING_Y * zoom)))); + LytRect contentViewport = resolveNodeContentRect(node, rect, paddingX, contentY, activeZoom); LytRect clip = intersect(viewport, contentViewport); if (clip == null) { return; } context.pushLocalScissor(clip); try { + int originX = contentViewport.x() - Math.round( + node.contentLayout.visualBounds() + .x() * activeZoom); + int originY = contentViewport.y() - Math.round( + node.contentLayout.visualBounds() + .y() * activeZoom); NodeContentRenderContext nodeContext = new NodeContentRenderContext( context, clip, - contentViewport.x(), - contentViewport.y(), - zoom); - node.contentLayout.block() - .render(nodeContext); + originX, + originY, + activeZoom); + renderNodeContentBlock(node.contentLayout.block(), nodeContext, context, contentViewport); } finally { context.popScissor(); } } + private void renderNodeContentBlock(LytBlock block, NodeContentRenderContext nodeContext, + RenderContext nativeContext, LytRect contentViewport) { + if (block instanceof LytNode container && !container.getChildren() + .isEmpty()) { + for (var child : new ArrayList<>(container.getChildren())) { + if (child instanceof LytBlock childBlock) { + renderNodeContentBlock(childBlock, nodeContext, nativeContext, contentViewport); + } + } + renderContainerDecoration(container, nodeContext); + } else if (usesRawGl(block)) { + GL11.glPushMatrix(); + GL11.glTranslatef(nodeContext.getDocumentOriginX(), nodeContext.getDocumentOriginY(), 0f); + GL11.glScalef(nodeContext.getScale(), nodeContext.getScale(), 1f); + try { + block.render(nodeContext); + } finally { + GL11.glPopMatrix(); + } + } else { + block.render(nodeContext); + } + } + + private void refreshFlowHover() { + if (!lastPickValid || layout == null) return; + NodeHit hit = pickNodeHit(lastPickDocX, lastPickDocY); + LytFlowContent hoveredFlow = null; + LytParagraph hoveredParagraph = null; + if (hit != null) { + for (var content : hit.flowPath() + .targets()) { + if (content instanceof InteractiveElement) { + hoveredFlow = content; + break; + } + } + if (hoveredFlow != null) { + for (LytNode node = hit.node(); node != null; node = node.getParent()) { + if (node instanceof LytParagraph p) { + hoveredParagraph = p; + break; + } + } + } + } + if (hoveredParagraph != lastFlowHoverParagraph || hoveredFlow != lastFlowHoverContent) { + if (lastFlowHoverParagraph != null) lastFlowHoverParagraph.onMouseLeave(); + if (hoveredParagraph != null) hoveredParagraph.onMouseEnter(hoveredFlow); + lastFlowHoverParagraph = hoveredParagraph; + lastFlowHoverContent = hoveredFlow; + } + } + + private static boolean containsScene(@Nullable LytBlock block) { + if (block == null) return false; + if (block instanceof LytGuidebookScene) return true; + if (block instanceof LytNode container) { + for (var child : container.getChildren()) { + if (child instanceof LytBlock childBlock && containsScene(childBlock)) return true; + } + } + return false; + } + + private static void renderContainerDecoration(LytNode container, RenderContext context) { + if (!(container instanceof LytBox box)) return; + LytRect b = container.getBounds(); + if (box.getBackgroundColor() != null) { + context.fillRect(b, box.getBackgroundColor()); + } + int topW = box.getBorderTop() + .width(); + int bottomW = box.getBorderBottom() + .width(); + if (topW > 0) { + context.fillRect( + b.x(), + b.y(), + b.width(), + topW, + context.resolveColor( + box.getBorderTop() + .color())); + } + if (bottomW > 0) { + context.fillRect( + b.x(), + b.bottom() - bottomW, + b.width(), + bottomW, + context.resolveColor( + box.getBorderBottom() + .color())); + } + } + + private static boolean usesRawGl(LytBlock block) { + return block instanceof LytLatexBlock || block instanceof LytLatexDisplayBlock + || block instanceof LytItemImage + || block instanceof LytNeiRecipeBox; + } + private @Nullable NodeHit pickNodeHit(int documentX, int documentY) { if (layout == null) { return null; } LytRect viewport = getInnerViewport(); - int baseX = viewport.x() + contentOffsetX + float activeZoom = visualZoom.value(); + int baseX = viewport.x() + visualContentOffsetX.rounded() - Math.round( layout.contentBounds() - .x() * zoom); - int baseY = viewport.y() + contentOffsetY + .x() * activeZoom); + int baseY = viewport.y() + visualContentOffsetY.rounded() - Math.round( layout.contentBounds() - .y() * zoom); + .y() * activeZoom); List contentNodes = layout.contentNodes(); for (int index = contentNodes.size() - 1; index >= 0; index--) { NodeLayout node = contentNodes.get(index); - LytRect contentScreenRect = getNodeContentScreenRect(node, baseX, baseY); + LytRect contentScreenRect = getNodeContentScreenRect(node, baseX, baseY, activeZoom); if (contentScreenRect == null || !contentScreenRect.contains(documentX, documentY)) { continue; } - int localX = unscaleCoordinate(documentX - contentScreenRect.x()); - int localY = unscaleCoordinate(documentY - contentScreenRect.y()); + int localX = unscaleCoordinate(documentX - contentScreenRect.x(), activeZoom); + int localY = unscaleCoordinate(documentY - contentScreenRect.y(), activeZoom); DocumentInteractionSnapshot hit = LytDocument.pick(node.contentLayout.block(), localX, localY); if (hit != null) { return new NodeHit(hit.node(), hit.flowPath(), localX, localY); @@ -700,46 +853,55 @@ private void renderNodeContent(RenderContext context, NodeLayout node, LytRect r return null; } - private @Nullable LytRect getNodeContentScreenRect(NodeLayout node, int baseX, int baseY) { + private @Nullable LytRect getNodeContentScreenRect(NodeLayout node, int baseX, int baseY, float activeZoom) { if (node.contentLayout == null) { return null; } - return resolveNodeContentRect(node, baseX, baseY); + LytRect nodeRect = new LytRect( + scaled(baseX, node.x, activeZoom), + scaled(baseY, node.y, activeZoom), + Math.max(1, Math.round(node.width * activeZoom)), + Math.max(1, Math.round(node.height * activeZoom))); + int paddingX = Math.max(1, Math.round(NODE_PADDING_X * activeZoom)); + int contentY = nodeRect.y() + Math.max(1, Math.round(NODE_PADDING_Y * activeZoom)) + + resolveNodeBadgeHeight(node, activeZoom); + return resolveNodeContentRect(node, nodeRect, paddingX, contentY, activeZoom); } private int contextLineHeight(ResolvedTextStyle style) { return Math.max(1, Math.round((9 + 1) * style.fontScale())); } - private LytRect resolveNodeContentRect(NodeLayout node, int baseX, int baseY) { - LytRect nodeRect = new LytRect( - scaled(baseX, node.x), - scaled(baseY, node.y), - Math.max(1, Math.round(node.width * zoom)), - Math.max(1, Math.round(node.height * zoom))); - int paddingX = Math.max(1, Math.round(NODE_PADDING_X * zoom)); - int paddingY = Math.max(1, Math.round(NODE_PADDING_Y * zoom)); - int textY = nodeRect.y() + paddingY + resolveNodeBadgeHeight(node); + private LytRect resolveNodeContentRect(NodeLayout node, LytRect nodeRect, int paddingX, int contentY, + float activeZoom) { return new LytRect( nodeRect.x() + paddingX, - textY, - Math.max(1, Math.round(node.contentLayout.width() * zoom)), - Math.max(1, Math.round(node.contentLayout.height() * zoom))); + contentY, + Math.max( + 1, + Math.round( + node.contentLayout.visualBounds() + .width() * activeZoom)), + Math.max( + 1, + Math.round( + node.contentLayout.visualBounds() + .height() * activeZoom))); } - private int resolveNodeBadgeHeight(NodeLayout node) { + private int resolveNodeBadgeHeight(NodeLayout node, float activeZoom) { if (!node.showBadge || node.badgeText == null) { return 0; } - ensureScaledStyles(); + ensureScaledStyles(activeZoom); ResolvedTextStyle badgeStyle = scaledIconTextStyle; - int badgePaddingY = Math.max(1, Math.round(2 * zoom)); - int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * zoom)); + int badgePaddingY = Math.max(1, Math.round(2 * activeZoom)); + int iconGapY = Math.max(1, Math.round(ICON_GAP_Y * activeZoom)); return contextLineHeight(badgeStyle) + badgePaddingY * 2 + iconGapY; } - private int unscaleCoordinate(int coordinate) { - return Math.max(0, Math.round(coordinate / Math.max(zoom, 0.0001f))); + private int unscaleCoordinate(int coordinate, float activeZoom) { + return Math.max(0, Math.round(coordinate / Math.max(activeZoom, 0.0001f))); } private @Nullable LytRect intersect(LytRect a, LytRect b) { @@ -753,6 +915,78 @@ private int unscaleCoordinate(int coordinate) { return new LytRect(left, top, right - left, bottom - top); } + private void restoreViewportAfterLayout(@Nullable DiagramLayout previousLayout, int previousOffsetX, + int previousOffsetY, int previousViewportWidth, int previousViewportHeight, int viewportWidth, + int viewportHeight) { + if (previousLayout == null) { + centerDiagram(viewportWidth + CANVAS_PADDING * 2, viewportHeight + CANVAS_PADDING * 2); + return; + } + float anchorX = previousLayout.contentBounds() + .x() + (previousViewportWidth * 0.5f - previousOffsetX) / Math.max(zoom, 0.0001f); + float anchorY = previousLayout.contentBounds() + .y() + (previousViewportHeight * 0.5f - previousOffsetY) / Math.max(zoom, 0.0001f); + contentOffsetX = Math.round( + viewportWidth * 0.5f - (anchorX - layout.contentBounds() + .x()) * zoom); + contentOffsetY = Math.round( + viewportHeight * 0.5f - (anchorY - layout.contentBounds() + .y()) * zoom); + clampOffsets(); + } + + private LytRect resolveNodeVisualRect(NodeLayout node) { + LytRect nodeRect = new LytRect(node.x, node.y, node.width, node.height); + if (node.contentLayout == null) { + return nodeRect; + } + int contentY = node.y + NODE_PADDING_Y + resolveNodeBadgeHeightUnscaled(node); + LytRect contentRect = new LytRect( + node.x + NODE_PADDING_X, + contentY, + node.contentLayout.visualBounds() + .width(), + node.contentLayout.visualBounds() + .height()); + return LytRect.union(nodeRect, contentRect); + } + + private int resolveNodeBadgeHeightUnscaled(NodeLayout node) { + if (!node.showBadge || node.badgeText == null) { + return 0; + } + return contextLineHeight(ICON_TEXT_STYLE) + 4 + ICON_GAP_Y; + } + + private LytRect resolveBlockVisualBounds(LytBlock block) { + LytRect[] result = { LytRect.empty() }; + block.visit(new LytVisitor() { + + @Override + public Result beforeNode(LytNode node) { + if (node instanceof LytBlock childBlock) { + result[0] = LytRect.union(result[0], resolveSelfVisualBounds(childBlock)); + } + return Result.CONTINUE; + } + }); + return result[0]; + } + + private LytRect resolveSelfVisualBounds(LytBlock block) { + LytRect bounds = block.getBounds(); + if (bounds == null) { + return LytRect.empty(); + } + if (block instanceof LytLatexBlock latexBlock) { + return latexBlock.getVisualBounds(); + } + if (block instanceof LytLatexDisplayBlock latexDisplayBlock) { + return latexDisplayBlock.getVisualBounds(); + } + return bounds; + } + private NodeColors resolveColors(MermaidMindmapNode node) { int accent = 0xFF7AA2F7; for (String className : node.getClasses()) { @@ -949,6 +1183,9 @@ private void centerDiagram(int viewportWidth, int viewportHeight) { int innerHeight = Math.max(1, viewportHeight - CANVAS_PADDING * 2); contentOffsetX = (innerWidth - Math.round(layout.diagramWidth * zoom)) / 2; contentOffsetY = (innerHeight - Math.round(layout.diagramHeight * zoom)) / 2; + visualContentOffsetX.snapTo(contentOffsetX); + visualContentOffsetY.snapTo(contentOffsetY); + visualZoom.snapTo(zoom); clampOffsets(); } @@ -963,6 +1200,12 @@ private void clampOffsets() { contentOffsetY = clampAxis(contentOffsetY, innerHeight, Math.round(layout.diagramHeight * zoom)); } + private void updateVisualState() { + visualContentOffsetX.updateTowards(contentOffsetX, 26f, 0.05f, 0.01f, Math.max(128f, bounds.width() * 2f)); + visualContentOffsetY.updateTowards(contentOffsetY, 26f, 0.05f, 0.01f, Math.max(128f, bounds.height() * 2f)); + visualZoom.updateTowards(zoom, 24f, 0.05f, 0.0001f, 4f); + } + private int clampAxis(int offset, int viewportSize, int contentSize) { if (contentSize <= viewportSize) { return (viewportSize - contentSize) / 2; @@ -984,13 +1227,13 @@ private int resolvePreferredViewportWidth() { return preferredWidth > 0 ? preferredWidth : MIN_WIDTH; } - private int scaled(int base, int value) { - return base + Math.round(value * zoom); + private int scaled(int base, int value, float activeZoom) { + return base + Math.round(value * activeZoom); } - private ResolvedTextStyle scaleStyle(ResolvedTextStyle baseStyle) { + private ResolvedTextStyle scaleStyle(ResolvedTextStyle baseStyle, float activeZoom) { return new ResolvedTextStyle( - baseStyle.fontScale() * zoom, + baseStyle.fontScale() * activeZoom, baseStyle.bold(), baseStyle.italic(), baseStyle.underlined(), @@ -1003,19 +1246,20 @@ private ResolvedTextStyle scaleStyle(ResolvedTextStyle baseStyle) { baseStyle.whiteSpace(), baseStyle.alignment(), baseStyle.dropShadow(), - baseStyle.backgroundColor()); + baseStyle.backgroundColor(), + baseStyle.inlineCode()); } - private void ensureScaledStyles() { - if (Float.compare(scaledStyleZoom, zoom) == 0 && scaledRootTextStyle != null + private void ensureScaledStyles(float activeZoom) { + if (Float.compare(scaledStyleZoom, activeZoom) == 0 && scaledRootTextStyle != null && scaledNodeTextStyle != null && scaledIconTextStyle != null) { return; } - scaledStyleZoom = zoom; - scaledRootTextStyle = scaleStyle(ROOT_TEXT_STYLE); - scaledNodeTextStyle = scaleStyle(NODE_TEXT_STYLE); - scaledIconTextStyle = scaleStyle(ICON_TEXT_STYLE); + scaledStyleZoom = activeZoom; + scaledRootTextStyle = scaleStyle(ROOT_TEXT_STYLE, activeZoom); + scaledNodeTextStyle = scaleStyle(NODE_TEXT_STYLE, activeZoom); + scaledIconTextStyle = scaleStyle(ICON_TEXT_STYLE, activeZoom); } LytRect getContentBoundsForTesting() { @@ -1030,25 +1274,19 @@ public interface AdvanceFunction { public static class NodeContentLayout { private final LytBlock block; - private final int width; - private final int height; + private final LytRect visualBounds; - public NodeContentLayout(LytBlock block, int width, int height) { + public NodeContentLayout(LytBlock block, LytRect visualBounds) { this.block = block; - this.width = Math.max(1, width); - this.height = Math.max(1, height); + this.visualBounds = visualBounds != null && !visualBounds.isEmpty() ? visualBounds : LytRect.empty(); } public LytBlock block() { return block; } - public int width() { - return width; - } - - public int height() { - return height; + public LytRect visualBounds() { + return visualBounds; } } @@ -1125,9 +1363,18 @@ public int getDocumentOriginY() { return originY; } + public float getScale() { + return scale; + } + @Override public LytRect toScreenRect(LytRect rect) { - return scaleRect(rect); + LytRect s = scaleRect(rect); + return new LytRect( + s.x() + delegate.getDocumentOriginX(), + s.y() + delegate.getDocumentOriginY() - delegate.getScrollOffsetY(), + s.width(), + s.height()); } @Override @@ -1203,6 +1450,33 @@ private void renderScaledItem(ItemStack stack, int x, int y, boolean overlay) { } } + @Override + public void blitGuiSprite(LytRect rect, GuiSprite sprite) { + if (sprite == null) return; + int sx = scaleX(rect.x()); + int sy = scaleY(rect.y()); + GL11.glPushMatrix(); + GL11.glTranslatef(sx, sy, 0f); + GL11.glScalef(scale, scale, 1f); + try { + delegate.blitTexture( + sprite.getTexture(), + 0, + 0, + sprite.getU(), + sprite.getV(), + sprite.getWidth(), + sprite.getHeight()); + } finally { + GL11.glPopMatrix(); + } + } + + @Override + public void fillIcon(LytRect rect, GuiSprite sprite, ColorValue color) { + delegate.fillIcon(scaleRect(rect), sprite, color); + } + @Override public void blitTexture(ResourceLocation texture, int x, int y, int u, int v, int width, int height) { delegate.blitTexture(texture, scaleX(x), scaleY(y), u, v, scaleLength(width), scaleLength(height)); @@ -1262,6 +1536,13 @@ public void pushScissor(LytRect rect) { delegate.pushScissor(scaleRect(rect)); } + @Override + public void pushLocalScissor(LytRect rect) { + // Override the default interface method to avoid double-scaling: + // the default chains toScreenRect(scale) + pushScissor(scale again). + delegate.pushScissor(scaleRect(rect)); + } + @Override public LytRect currentScissor() { return delegate.currentScissor(); @@ -1277,6 +1558,18 @@ public void restoreExternalRenderState() { delegate.restoreExternalRenderState(); } + @Override + public void beginLocalView() { + GL11.glPushMatrix(); + GL11.glTranslatef(originX, originY, 0f); + GL11.glScalef(scale, scale, 1f); + } + + @Override + public void endLocalView() { + GL11.glPopMatrix(); + } + private ResolvedTextStyle scaleStyle(ResolvedTextStyle style) { return scaledStyleCache.computeIfAbsent( style, @@ -1294,7 +1587,8 @@ private ResolvedTextStyle scaleStyle(ResolvedTextStyle style) { key.whiteSpace(), key.alignment(), key.dropShadow(), - key.backgroundColor())); + key.backgroundColor(), + key.inlineCode())); } private LytRect scaleRect(LytRect rect) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytNode.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytNode.java index 168c1cc9..5691f71b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytNode.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytNode.java @@ -24,8 +24,30 @@ public abstract class LytNode implements Styleable { @Nullable private MdAstNode sourceNode; + @Nullable + private String id; + + @Nullable + private String nodeUid; + + @Nullable + private String styleClass; + public void removeChild(LytNode node) {} + public void replaceChild(LytNode oldChild, LytNode newChild) { + throw new UnsupportedOperationException(getClass().getSimpleName() + " must override replaceChild"); + } + + protected void onAttach() {} + + protected void onDetach() {} + + public boolean isAttached() { + LytDocument doc = getDocument(); + return doc != null && doc.isLive(); + } + public List getChildren() { return List.of(); } @@ -151,4 +173,31 @@ public void setHoverStyle(TextStyle style) { public void setSourceNode(@Nullable MdAstNode sourceNode) { this.sourceNode = sourceNode; } + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable String id) { + this.id = id; + } + + @Nullable + public String getNodeUid() { + return nodeUid; + } + + public void setNodeUid(@Nullable String nodeUid) { + this.nodeUid = nodeUid; + } + + @Nullable + public String getStyleClass() { + return styleClass; + } + + public void setStyleClass(@Nullable String styleClass) { + this.styleClass = styleClass; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java index 641f69ec..4dc6f19d 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytParagraph.java @@ -4,6 +4,8 @@ import org.jetbrains.annotations.Nullable; +import com.hfstudio.guidenh.guide.color.ConstantColor; +import com.hfstudio.guidenh.guide.color.SymbolicColor; import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.flow.LytFlowContainer; import com.hfstudio.guidenh.guide.document.flow.LytFlowContent; @@ -11,6 +13,7 @@ import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.layout.flow.FlowBuilder; import com.hfstudio.guidenh.guide.render.RenderContext; +import com.hfstudio.guidenh.guide.style.TextStyle; public class LytParagraph extends LytBlock implements LytFlowContainer { @@ -187,4 +190,51 @@ public static LytParagraph of(String text) { paragraph.appendText(text); return paragraph; } + + /** + * The text style used for loading placeholders: gray, italic, obfuscated. + */ + public static final TextStyle LOADING_STYLE = TextStyle.builder() + .italic(true) + .obfuscated(true) + .color(new ConstantColor(0xFF808080)) + .build(); + + /** + * Creates a placeholder paragraph with distinctive "loading" visual style + * (gray, italic, obfuscated text) so pending materialization is obvious. + */ + public static LytParagraph loading(String text) { + var paragraph = new LytParagraph(); + paragraph.setStyle(LOADING_STYLE); + paragraph.appendText(text); + return paragraph; + } + + /** Warm amber-yellow italic text for placeholder blocks awaiting async materialization. */ + public static final TextStyle PLACEHOLDER_STYLE = TextStyle.builder() + .italic(true) + .color(new ConstantColor(0xFFE8A317)) + .build(); + + /** Red text style for inline error messages. */ + public static final TextStyle ERROR_STYLE = TextStyle.builder() + .color(SymbolicColor.ERROR_TEXT) + .build(); + + /** Creates a placeholder paragraph (amber, italic) for deferred content. */ + public static LytParagraph placeholder(String text) { + var paragraph = new LytParagraph(); + paragraph.setStyle(PLACEHOLDER_STYLE); + paragraph.appendText(text); + return paragraph; + } + + /** Creates an error paragraph (red text) for inline error reporting. */ + public static LytParagraph error(String text) { + var paragraph = new LytParagraph(); + paragraph.setStyle(ERROR_STYLE); + paragraph.appendText(text); + return paragraph; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java index 2f11944e..8fc3e4fd 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytPlaceholderBlock.java @@ -8,8 +8,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.RenderContext; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; /** * This layout block shows a loading indicator and will ultimately replace itself with the final content. @@ -45,8 +44,7 @@ private void setCurrent(LytBlock block) { private void onLoad(LytBlock element, Throwable error) { if (error != null || element == null) { - FMLLog.getLogger() - .error("[GuideNH] [LytPlaceholderBlock] Failed to load an asynchronous guide element.", error); + GuideDebugLog.error("[GuideNH] [LytPlaceholderBlock] Failed to load an asynchronous guide element.", error); var errorParagraph = new LytParagraph(); errorParagraph.setStyle(DefaultStyles.ERROR_TEXT); if (error == null) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java index 6fc5a8e3..5f22d742 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytQuoteBox.java @@ -90,6 +90,11 @@ public void removeChild(LytNode node) { content.removeChild(node); } + @Override + public void replaceChild(LytNode oldChild, LytNode newChild) { + root.replaceChild(oldChild, newChild); + } + @Override public List getChildren() { return root.getChildren(); diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java index 9bef6be1..f7172839 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/LytSizeBox.java @@ -5,6 +5,7 @@ import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.interaction.DocumentDragTarget; import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorVerticalScrollbar; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; import com.hfstudio.guidenh.guide.layout.LayoutContext; import com.hfstudio.guidenh.guide.render.RenderContext; @@ -25,6 +26,7 @@ public class LytSizeBox extends LytVBox implements DocumentDragTarget { private int viewportHeight; private int scrollOffsetY; private int appliedScrollOffsetY; + private final SmoothFloatState visualScrollOffsetY = new SmoothFloatState(); private boolean draggingContent; private int dragLastDocumentY; private boolean draggingScrollbar; @@ -67,6 +69,7 @@ protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int avai viewportHeight = preferredHeight > 0 ? preferredHeight : contentHeight; setScrollOffset(scrollOffsetY); + snapVisualScrollToTarget(); int totalWidth = preferredWidth > 0 ? measuredWidth : contentBounds.width() + (hasVerticalScroll() ? SCROLLBAR_WIDTH + SCROLLBAR_GAP : 0); @@ -75,6 +78,7 @@ protected LytRect computeBoxLayout(LayoutContext context, int x, int y, int avai @Override public void render(RenderContext context) { + updateVisualScroll(); if (!hasVerticalScroll()) { super.render(context); return; @@ -87,9 +91,7 @@ public void render(RenderContext context) { LytRect viewportBounds = getViewportBounds(); context.pushLocalScissor(viewportBounds); try { - for (LytBlock child : children) { - child.render(context); - } + renderChildrenWithVisualOffset(context); } finally { context.popScissor(); } @@ -254,8 +256,42 @@ private LytRect getScrollbarThumbBounds() { return LytRect.empty(); } - SceneEditorVerticalScrollbar.Thumb thumb = SceneEditorVerticalScrollbar - .computeThumb(trackBounds.y(), trackBounds.height(), contentHeight, viewportHeight, scrollOffsetY); + SceneEditorVerticalScrollbar.Thumb thumb = SceneEditorVerticalScrollbar.computeThumb( + trackBounds.y(), + trackBounds.height(), + contentHeight, + viewportHeight, + visualScrollOffsetY.rounded()); return new LytRect(trackBounds.x(), thumb.start(), trackBounds.width(), thumb.size()); } + + private void snapVisualScrollToTarget() { + visualScrollOffsetY.snapTo(scrollOffsetY); + } + + private void updateVisualScroll() { + visualScrollOffsetY.updateTowards(scrollOffsetY, 28f, 0.25f, 0.01f, Math.max(128f, viewportHeight * 2f)); + } + + private void renderChildrenWithVisualOffset(RenderContext context) { + int renderDeltaY = appliedScrollOffsetY - visualScrollOffsetY.rounded(); + if (renderDeltaY != 0) { + moveChildrenLayoutY(renderDeltaY); + } + try { + for (LytBlock child : children) { + child.render(context); + } + } finally { + if (renderDeltaY != 0) { + moveChildrenLayoutY(-renderDeltaY); + } + } + } + + private void moveChildrenLayoutY(int deltaY) { + for (LytBlock child : children) { + child.moveLayoutPos(0, deltaY); + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartIcon.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartIcon.java index a6e27c56..1b1b6ee4 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartIcon.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/ChartIcon.java @@ -1,23 +1,33 @@ package com.hfstudio.guidenh.guide.document.block.chart; +import net.minecraft.client.Minecraft; +import net.minecraft.item.Item; import net.minecraft.item.ItemStack; import net.minecraft.util.ResourceLocation; +import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; import com.hfstudio.guidenh.guide.render.GuidePageTexture; /** * Icon associated with a chart series/slice: either an {@link ItemStack} or a PNG resource. + * Supports deferred resolution so compilers can produce icons without accessing registries or I/O. */ public class ChartIcon { - private final ItemStack stack; - private final GuidePageTexture texture; - private final ResourceLocation imageId; + private ItemStack stack; + private GuidePageTexture texture; + private ResourceLocation imageId; + + private String deferredRawKey; + private int deferredMeta; + private ResourceLocation deferredImageId; + private boolean resolved; private ChartIcon(ItemStack stack, GuidePageTexture texture, ResourceLocation imageId) { this.stack = stack; this.texture = texture; this.imageId = imageId; + this.resolved = true; } public static ChartIcon ofItemStack(ItemStack stack) { @@ -28,23 +38,67 @@ public static ChartIcon ofImage(ResourceLocation id, GuidePageTexture texture) { return new ChartIcon(null, texture, id); } + public static ChartIcon ofDeferredItem(String rawKey, int meta) { + ChartIcon icon = new ChartIcon(null, null, null); + icon.resolved = false; + icon.deferredRawKey = rawKey; + icon.deferredMeta = meta; + return icon; + } + + public static ChartIcon ofDeferredImage(ResourceLocation id) { + ChartIcon icon = new ChartIcon(null, null, null); + icon.resolved = false; + icon.deferredImageId = id; + return icon; + } + + @SuppressWarnings("deprecation") + private void resolve() { + if (resolved) return; + resolved = true; + if (deferredRawKey != null) { + Item item = (Item) Item.itemRegistry.getObject(deferredRawKey); + if (item != null) { + stack = new ItemStack(item, 1, deferredMeta); + } + deferredRawKey = null; + } + if (deferredImageId != null) { + byte[] data = GuideResourceAccess.readBytes( + Minecraft.getMinecraft() + .getResourceManager(), + deferredImageId); + if (data != null) { + imageId = deferredImageId; + texture = GuidePageTexture.load(imageId, data); + } + deferredImageId = null; + } + } + public ItemStack getStack() { + resolve(); return stack; } public GuidePageTexture getTexture() { + resolve(); return texture; } public ResourceLocation getImageId() { + resolve(); return imageId; } public boolean hasItemStack() { + resolve(); return stack != null && stack.getItem() != null; } public boolean hasImage() { + resolve(); return texture != null && !texture.isMissing(); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java index 5fba12c7..3386a5e3 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/chart/LytChartBase.java @@ -330,7 +330,8 @@ public static ResolvedTextStyle textStyle(int argb) { WhiteSpaceMode.NORMAL, TextAlignment.LEFT, false, - null); + null, + false); } public static String formatPercent(double ratio) { 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 2f63705c..6154ed28 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 @@ -1282,7 +1282,8 @@ private static ResolvedTextStyle makeStyle(int argb, boolean bold) { WhiteSpaceMode.NORMAL, TextAlignment.LEFT, false, - null); + null, + false); } @SuppressWarnings("unused") diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java index 1f784d13..966dd46e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/block/table/LytTableRow.java @@ -35,4 +35,13 @@ public LytTableCell appendCell() { public List getChildren() { return cells; } + + @Override + @SuppressWarnings("unchecked") + public void replaceChild(LytNode oldChild, LytNode newChild) { + if (!(newChild instanceof LytTableCell)) return; + int idx = cells.indexOf(oldChild); + if (idx < 0) return; + cells.set(idx, (LytTableCell) newChild); + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowContent.java b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowContent.java index be3701d6..e8a8d0d6 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowContent.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowContent.java @@ -1,5 +1,8 @@ package com.hfstudio.guidenh.guide.document.flow; +import java.util.HashMap; +import java.util.Map; + import org.jetbrains.annotations.Nullable; import com.hfstudio.guidenh.guide.document.block.LytVisitor; @@ -13,6 +16,14 @@ public class LytFlowContent implements Styleable { private LytFlowParent parent; + @Nullable + private String styleClass; + + @Nullable + private String nodeUid; + + private final Map data = new HashMap<>(); + public LytFlowParent getParent() { return parent; } @@ -83,4 +94,34 @@ public final void visit(LytVisitor visitor) { } protected void visitChildren(LytVisitor visitor) {} + + @Nullable + public String getStyleClass() { + return styleClass; + } + + public void setStyleClass(@Nullable String styleClass) { + this.styleClass = styleClass; + } + + @Nullable + public String getNodeUid() { + return nodeUid; + } + + public void setNodeUid(@Nullable String nodeUid) { + this.nodeUid = nodeUid; + } + + public Object getData(String key) { + return data.get(key); + } + + public void setData(String key, Object value) { + data.put(key, value); + } + + public Map getData() { + return data; + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java index c5540d2f..c0fd8e42 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java +++ b/src/main/java/com/hfstudio/guidenh/guide/document/flow/LytFlowInlineBlock.java @@ -2,6 +2,8 @@ import java.util.Optional; +import org.jetbrains.annotations.Nullable; + import com.hfstudio.guidenh.guide.document.LytRect; import com.hfstudio.guidenh.guide.document.LytSize; import com.hfstudio.guidenh.guide.document.block.LytBlock; @@ -83,6 +85,25 @@ protected void visitChildren(LytVisitor visitor) { } } + /** + * Unwraps a flow-wrapped placeholder node. When a block-level tag appears in inline + * context, BlockTagCompiler wraps the placeholder in LytFlowInlineBlock. Dispatch + * passes the wrapper. This helper returns the inner placeholder regardless. + * + * @return the unwrapped placeholder of type T, or null if the node is neither + * a direct instance nor a LytFlowInlineBlock wrapping an instance + */ + @Nullable + public static T unwrapPlaceholder(Object node, Class placeholderClass) { + if (placeholderClass.isInstance(node)) { + return placeholderClass.cast(node); + } + if (node instanceof LytFlowInlineBlock wrapper && placeholderClass.isInstance(wrapper.getBlock())) { + return placeholderClass.cast(wrapper.getBlock()); + } + return null; + } + public static LytFlowInlineBlock of(LytBlock block) { var inlineBlock = new LytFlowInlineBlock(); inlineBlock.setBlock(block); 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 eeddb05d..deb2cfd8 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/ItemIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/ItemIndex.java @@ -14,8 +14,7 @@ import com.hfstudio.guidenh.guide.PageAnchor; import com.hfstudio.guidenh.guide.compiler.IdUtils; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; /** * An index of Minecraft items to the main guidebook page describing it. @@ -74,8 +73,8 @@ public static List> getItemAnchors(ParsedGuidePage page List itemIdList = normalizeItemIdEntries(page, itemIdsNode); if (itemIdList == null) { - FMLLog.getLogger() - .warn("[GuideNH] [ItemIndex] Page {} contains malformed item_ids frontmatter", page.getId()); + GuideDebugLog + .warnAlways("[GuideNH] [ItemIndex] Page {} contains malformed item_ids frontmatter", page.getId()); return List.of(); } @@ -98,30 +97,27 @@ public static List> getItemAnchors(ParsedGuidePage page page.getId() .getResourceDomain()); } catch (IllegalArgumentException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [ItemIndex] Page {} contains a malformed item_ids frontmatter entry: {}", - page.getId(), - listEntry); + GuideDebugLog.warnAlways( + "[GuideNH] [ItemIndex] Page {} contains a malformed item_ids frontmatter entry: {}", + page.getId(), + listEntry); continue; } if (itemId == null) { - FMLLog.getLogger() - .warn( - "[GuideNH] [ItemIndex] Page {} references an unknown item {} in its item_ids frontmatter", - page.getId(), - listEntry); + GuideDebugLog.warnAlways( + "[GuideNH] [ItemIndex] Page {} references an unknown item {} in its item_ids frontmatter", + page.getId(), + listEntry); continue; } itemAnchors.add(Pair.of(itemId, new PageAnchor(page.getId(), anchor))); } else { - FMLLog.getLogger() - .warn( - "[GuideNH] [ItemIndex] Page {} contains a malformed item_ids frontmatter entry: {}", - page.getId(), - listEntry); + GuideDebugLog.warnAlways( + "[GuideNH] [ItemIndex] Page {} contains a malformed item_ids frontmatter entry: {}", + page.getId(), + listEntry); } } @@ -136,8 +132,9 @@ private static List normalizeItemIdEntries(ParsedGuidePage page, Object itemI if (itemIdsNode instanceof String itemIdEntry) { String trimmed = itemIdEntry.trim(); if (trimmed.isEmpty()) { - FMLLog.getLogger() - .warn("[GuideNH] [ItemIndex] Page {} contains an empty item_ids frontmatter entry", page.getId()); + GuideDebugLog.warnAlways( + "[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 2de90593..52d5b4e2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/OreIndex.java @@ -17,8 +17,7 @@ import com.hfstudio.guidenh.guide.GuidePageChange; import com.hfstudio.guidenh.guide.PageAnchor; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; /** * An index of Forge ore-dictionary names to the main guidebook page describing them. @@ -116,8 +115,8 @@ public static List> getOreAnchors(ParsedGuidePage page) } if (!(oreIdsNode instanceof ListoreIdList)) { - FMLLog.getLogger() - .warn("[GuideNH] [OreIndex] Page {} contains malformed ore_ids frontmatter", page.getId()); + GuideDebugLog + .warnAlways("[GuideNH] [OreIndex] Page {} contains malformed ore_ids frontmatter", page.getId()); return List.of(); } @@ -127,17 +126,17 @@ public static List> getOreAnchors(ParsedGuidePage page) if (listEntry instanceof String oreName) { String trimmed = oreName.trim(); if (trimmed.isEmpty()) { - FMLLog.getLogger() - .warn("[GuideNH] [OreIndex] Page {} contains an empty ore_ids frontmatter entry", page.getId()); + GuideDebugLog.warnAlways( + "[GuideNH] [OreIndex] Page {} contains an empty ore_ids frontmatter entry", + page.getId()); continue; } oreAnchors.add(Pair.of(trimmed, new PageAnchor(page.getId(), null))); } else { - FMLLog.getLogger() - .warn( - "[GuideNH] [OreIndex] Page {} contains a malformed ore_ids frontmatter entry: {}", - page.getId(), - listEntry); + GuideDebugLog.warnAlways( + "[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 153e5d23..3b98bda9 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/indices/UniqueIndex.java +++ b/src/main/java/com/hfstudio/guidenh/guide/indices/UniqueIndex.java @@ -15,8 +15,7 @@ import com.google.gson.stream.JsonWriter; import com.hfstudio.guidenh.guide.GuidePageChange; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; /** * Maintains an index for any given page using a mapping function for keys and values of the index. @@ -100,15 +99,14 @@ private void addToIndex(ParsedGuidePage page) { var value = entry.getValue(); var previousPage = index.putIfAbsent(key, new Record<>(page.getId(), value)); if (previousPage != null) { - FMLLog.getLogger() - .warn( - "[GuideNH] [UniqueIndex] Key conflict in index {}: {} is used by pages {} and {}; keeping {} and ignoring {}", - name, - key, - previousPage.pageId, - page, - previousPage.pageId, - page.getId()); + GuideDebugLog.warnAlways( + "[GuideNH] [UniqueIndex] Key conflict in index {}: {} is used by pages {} and {}; keeping {} and ignoring {}", + name, + key, + previousPage.pageId, + page, + previousPage.pageId, + page.getId()); hadDuplicates = true; } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevWatcherPump.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevWatcherPump.java deleted file mode 100644 index 51df7b35..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevWatcherPump.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.hfstudio.guidenh.guide.internal; - -import cpw.mods.fml.common.FMLCommonHandler; -import cpw.mods.fml.common.eventhandler.SubscribeEvent; -import cpw.mods.fml.common.gameevent.TickEvent; - -public class GuideDevWatcherPump { - - public static final int DEFAULT_INTERVAL_TICKS = 20; - - public interface TickableGuide { - - boolean hasDevelopmentSources(); - - void tickDevelopmentSources(); - } - - private final int intervalTicks; - private int tickCounter; - - public GuideDevWatcherPump(int intervalTicks) { - this.intervalTicks = Math.max(1, intervalTicks); - } - - public static void init() { - if (!hasAnyDevelopmentSources(GuideRegistry.getAll())) { - return; - } - FMLCommonHandler.instance() - .bus() - .register(new GuideDevWatcherPump(DEFAULT_INTERVAL_TICKS)); - } - - @SubscribeEvent - public void onClientTick(TickEvent.ClientTickEvent event) { - if (event.phase != TickEvent.Phase.END) { - return; - } - - tick(GuideRegistry.getAll()); - } - - public void tick(Iterable guides) { - tickCounter++; - if (tickCounter < intervalTicks) { - return; - } - tickCounter = 0; - - for (TickableGuide guide : guides) { - if (guide.hasDevelopmentSources()) { - guide.tickDevelopmentSources(); - } - } - } - - public static boolean hasAnyDevelopmentSources(Iterable guides) { - for (TickableGuide guide : guides) { - if (guide.hasDevelopmentSources()) { - return true; - } - } - return false; - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevelopmentResourcePackWatcher.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevelopmentResourcePackWatcher.java index 545fa20d..8130775f 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevelopmentResourcePackWatcher.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideDevelopmentResourcePackWatcher.java @@ -9,12 +9,10 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; -import org.apache.logging.log4j.Logger; - import com.hfstudio.guidenh.config.ModConfig; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import cpw.mods.fml.common.FMLCommonHandler; -import cpw.mods.fml.common.FMLLog; import cpw.mods.fml.common.eventhandler.SubscribeEvent; import cpw.mods.fml.common.gameevent.TickEvent; import io.methvin.watcher.DirectoryChangeEvent; @@ -147,17 +145,12 @@ public void processPendingReloadForTests() { } private static void logInfo(String message, Object... args) { - Logger logger = FMLLog.getLogger(); - if (logger != null && ModConfig.debug.enableDebugMode) { - logger.info("[GuideNH] [GuideDevelopmentResourcePackWatcher] " + message, args); - } + GuideDebugLog + .info(ModConfig.debug.enableDebugMode, "[GuideNH] [GuideDevelopmentResourcePackWatcher] " + message, args); } private static void logError(String message, Object arg, Throwable throwable) { - Logger logger = FMLLog.getLogger(); - if (logger != null) { - logger.error("[GuideNH] [GuideDevelopmentResourcePackWatcher] " + message, arg, throwable); - } + GuideDebugLog.error("[GuideNH] [GuideDevelopmentResourcePackWatcher] " + message, arg, throwable); } private final class Listener implements DirectoryChangeListener { @@ -176,11 +169,8 @@ public boolean isWatching() { @Override public void onException(Exception e) { - Logger logger = FMLLog.getLogger(); - if (logger != null) { - logger - .error("[GuideNH] [GuideDevelopmentResourcePackWatcher] Failed watching development resources", e); - } + GuideDebugLog + .error("[GuideNH] [GuideDevelopmentResourcePackWatcher] Failed watching development resources", e); } } } 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 3eaaab9e..dd648e2e 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideLightweightReloadService.java @@ -14,7 +14,7 @@ import org.jetbrains.annotations.Nullable; import com.github.bsideup.jabel.Desugar; -import com.hfstudio.guidenh.config.ModConfig; +import com.hfstudio.guidenh.ClientProxy; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; import com.hfstudio.guidenh.guide.internal.datadriven.DataDrivenGuideLoader; import com.hfstudio.guidenh.guide.internal.datadriven.GuidePageResourceSelector; @@ -25,10 +25,13 @@ import com.hfstudio.guidenh.guide.internal.recipe.RecipeCache; import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; import com.hfstudio.guidenh.guide.internal.util.LangUtil; +import com.hfstudio.guidenh.guide.latex.GuideLatexTextureCache; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiTranslationStats; import com.hfstudio.guidenh.guide.render.GuidePageTexture; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.cache.GuideSceneStructureCache; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; +import com.hfstudio.guidenh.integration.structurelib.StructureLibElementTooltipResolver; +import com.hfstudio.guidenh.integration.structurelib.StructureLibRuntimeFacade; public class GuideLightweightReloadService { @@ -43,20 +46,29 @@ public static void reloadDevelopmentGuides() { } public static void reloadGuides(IResourceManager resourceManager) { - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info("[GuideNH] [GuideLightweightReloadService] Reloading guide data..."); - } + GuideDebugLog.info("[GuideNH] [GuideLightweightReloadService] Reloading guide data..."); long startedAt = System.nanoTime(); - var activeResourcePacks = DataDrivenGuideLoader.getActiveResourcePacks(); + var activeResourcePacks = DataDrivenGuideLoader.getActiveResourcePacks(resourceManager); + DataDrivenGuideLoader.clearCaches(); RecipeCache.clear(); NeiAnimationTicker.clear(); GuidePageTexture.clear(); + GuideResourceAccess.clearCache(); GuidePageLanguageIndex.clear(); GuideResourceLanguageIndex.clear(); + GuideLatexTextureCache.INSTANCE.clearAll(); + GuideSceneStructureCache.global() + .clear(); + StructureLibRuntimeFacade.CONTROL_ANALYSIS_CACHE.clear(); + StructureLibRuntimeFacade.ANALYSIS_SNAPSHOT_CACHE.clear(); + StructureLibRuntimeFacade.IMPORT_RESULT_CACHE.clear(); + StructureLibElementTooltipResolver.BLOCK_CANDIDATE_CACHE.clear(); + StructureLibElementTooltipResolver.HATCH_CANDIDATE_CACHE.clear(); + ClientProxy.getLytHost() + .clearPageCaches(); long stageStartedAt = System.nanoTime(); - GuideRegistry.setDataDriven(DataDrivenGuideLoader.load()); + GuideRegistry.setDataDriven(DataDrivenGuideLoader.load(activeResourcePacks)); MediaWikiTranslationStats.invalidateCache(); long dataDrivenLoadNs = System.nanoTime() - stageStartedAt; @@ -86,20 +98,13 @@ public static void reloadGuides(IResourceManager resourceManager) { GuideRegistry.invalidateMergedNavigationTree(); long registryUpdateNs = System.nanoTime() - stageStartedAt; - stageStartedAt = System.nanoTime(); - GuideWarmupPump.clearScheduler(); - for (MutableGuide guide : GuideRegistry.getAll()) { - guide.resetWarmup(); - } - long warmupResetNs = System.nanoTime() - stageStartedAt; - stageStartedAt = System.nanoTime(); try { GuideME.getSearch() .indexAll(); } catch (Throwable t) { - FMLLog.getLogger() - .warn("[GuideNH] [GuideLightweightReloadService] Failed to reindex search after reload", t); + GuideDebugLog + .warnAlways("[GuideNH] [GuideLightweightReloadService] Failed to reindex search after reload", t); } long searchIndexNs = System.nanoTime() - stageStartedAt; @@ -107,20 +112,16 @@ public static void reloadGuides(IResourceManager resourceManager) { int loadedLanguageCount = countLoadedLanguages(guidePages); long totalNs = System.nanoTime() - startedAt; - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideLightweightReloadService] Guide reload complete, loaded {} guides, {} pages, {} languages in {} ns (dataDrivenLoadNs={}, pageLoadNs={}, registryUpdateNs={}, warmupResetNs={}, searchIndexNs={})", - guidePages.size(), - loadedPageCount, - loadedLanguageCount, - totalNs, - dataDrivenLoadNs, - pageLoadNs, - registryUpdateNs, - warmupResetNs, - searchIndexNs); - } + GuideDebugLog.info( + "[GuideNH] [GuideLightweightReloadService] Guide reload complete, loaded {} guides, {} pages, {} languages in {} ns (dataDrivenLoadNs={}, pageLoadNs={}, registryUpdateNs={}, searchIndexNs={})", + guidePages.size(), + loadedPageCount, + loadedLanguageCount, + totalNs, + dataDrivenLoadNs, + pageLoadNs, + registryUpdateNs, + searchIndexNs); } /** @@ -135,7 +136,7 @@ static Map loadPages(IResourceManager resourc defaultLanguage, currentLanguage, new LinkedHashMap<>(), - DataDrivenGuideLoader.getActiveResourcePacks()); + DataDrivenGuideLoader.getActiveResourcePacks(resourceManager)); } static Map loadPages(IResourceManager resourceManager, ResourceLocation guideId, @@ -144,7 +145,11 @@ static Map loadPages(IResourceManager resourc Iterable activeResourcePacks) { long startedAt = System.nanoTime(); var pages = new HashMap(); - var pagePaths = pagePathsForGuide(guideId, folder, pagePathCache, DataDrivenGuideLoader::discoverPagePaths); + var pagePaths = pagePathsForGuide( + guideId, + folder, + pagePathCache, + lookupFolder -> DataDrivenGuideLoader.discoverPagePaths(lookupFolder, activeResourcePacks)); String lang = currentLanguage != null ? currentLanguage : defaultLanguage; String sourceNamespace = guideId.getResourceDomain(); String sourcePack = "resources:" + sourceNamespace; @@ -168,8 +173,8 @@ static Map loadPages(IResourceManager resourc ParsedGuidePage parsed = loadResult != null ? loadResult.page() : null; if (parsed == null) { failedLoads++; - FMLLog.getLogger() - .warn("[GuideNH] [GuideLightweightReloadService] Failed to load guide page {}", pageId); + GuideDebugLog + .warnAlways("[GuideNH] [GuideLightweightReloadService] Failed to load guide page {}", pageId); continue; } switch (loadResult.kind()) { @@ -189,22 +194,19 @@ static Map loadPages(IResourceManager resourc } long totalNs = System.nanoTime() - startedAt; - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideLightweightReloadService] Loaded {} pages for guide {} folder {} requestedLanguage={} defaultLanguage={} discoveredPaths={} localizedHits={} defaultLanguageHits={} rawSourceHits={} failedLoads={} durationNs={}", - pages.size(), - guideId, - folder, - lang, - defaultLanguage, - pagePaths.size(), - localizedHits, - defaultLanguageHits, - rawSourceHits, - failedLoads, - totalNs); - } + GuideDebugLog.info( + "[GuideNH] [GuideLightweightReloadService] Loaded {} pages for guide {} folder {} requestedLanguage={} defaultLanguage={} discoveredPaths={} localizedHits={} defaultLanguageHits={} rawSourceHits={} failedLoads={} durationNs={}", + pages.size(), + guideId, + folder, + lang, + defaultLanguage, + pagePaths.size(), + localizedHits, + defaultLanguageHits, + rawSourceHits, + failedLoads, + totalNs); return pages; } @@ -292,19 +294,20 @@ private static ParsedGuidePage tryParsePageCandidate(String sourcePack, String l private static ParsedGuidePage parsePageBytes(String sourcePack, String language, String contentRootFolder, ResourceLocation pageId, ResourceLocation sourceId, byte[] bytes) { try { - return GuideLocalizedPageSourceResolver.parse(sourcePack, language, contentRootFolder, pageId, bytes); + return GuideLocalizedPageSourceResolver + .parseFrontmatterOnly(sourcePack, language, contentRootFolder, pageId, bytes); } catch (Exception ex) { - FMLLog.getLogger() + GuideDebugLog .error("[GuideNH] [GuideLightweightReloadService] Error parsing page {} from {}", pageId, sourceId, ex); return null; } } - static @Nullable byte[] selectPageCandidate(ResourceLocation sourceId) { + static byte @Nullable [] selectPageCandidate(ResourceLocation sourceId) { return selectPageCandidate(sourceId, DataDrivenGuideLoader.getActiveResourcePacks()); } - static @Nullable byte[] selectPageCandidate(ResourceLocation sourceId, + static byte @Nullable [] selectPageCandidate(ResourceLocation sourceId, Iterable resourcePacks) { GuidePageResourceSelector.SelectedPageResource winner = GuidePageResourceSelector .select(sourceId, resourcePacks); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideME.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideME.java index 20f35f2a..830f9e87 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideME.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideME.java @@ -6,8 +6,7 @@ import com.hfstudio.guidenh.GuideNH; import com.hfstudio.guidenh.guide.internal.search.GuideSearch; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class GuideME { @@ -44,8 +43,7 @@ public static synchronized void closeSearch() { try { SEARCH.close(); } catch (Exception e) { - FMLLog.getLogger() - .error("[GuideNH] [GuideME] Failed to close GuideSearch", e); + GuideDebugLog.error("[GuideNH] [GuideME] Failed to close GuideSearch", e); } finally { SEARCH = null; } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideOnStartup.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideOnStartup.java index 6b66ad77..df77fefe 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideOnStartup.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideOnStartup.java @@ -7,9 +7,9 @@ import net.minecraft.util.ResourceLocation; import com.hfstudio.guidenh.guide.PageAnchor; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import cpw.mods.fml.common.FMLCommonHandler; -import cpw.mods.fml.common.FMLLog; import cpw.mods.fml.common.eventhandler.SubscribeEvent; import cpw.mods.fml.common.gameevent.TickEvent; @@ -68,18 +68,16 @@ public void onClientTick(TickEvent.ClientTickEvent event) { for (ResourceLocation guideId : guidesToValidate) { MutableGuide guide = GuideRegistry.getById(guideId); if (guide == null) { - FMLLog.getLogger() - .error( - "[GuideNH] [GuideOnStartup] Cannot validate guide '{}' because it is not registered.", - guideId); + GuideDebugLog.error( + "[GuideNH] [GuideOnStartup] Cannot validate guide '{}' because it is not registered.", + guideId); continue; } try { guide.validateAll(); } catch (RuntimeException e) { - FMLLog.getLogger() - .error("[GuideNH] [GuideOnStartup] Failed to validate guide '{}'", guideId, e); + GuideDebugLog.error("[GuideNH] [GuideOnStartup] Failed to validate guide '{}'", guideId, e); } } @@ -89,10 +87,9 @@ public void onClientTick(TickEvent.ClientTickEvent event) { MutableGuide guide = GuideRegistry.getById(showOnStartup.guideId()); if (guide == null) { - FMLLog.getLogger() - .error( - "[GuideNH] [GuideOnStartup] Cannot open guide '{}' because it is not registered.", - showOnStartup.guideId()); + GuideDebugLog.error( + "[GuideNH] [GuideOnStartup] Cannot open guide '{}' because it is not registered.", + showOnStartup.guideId()); return; } @@ -100,8 +97,7 @@ public void onClientTick(TickEvent.ClientTickEvent event) { PageAnchor anchor = showOnStartup.anchor(); GuideScreen.open(showOnStartup.guideId(), anchor); } catch (RuntimeException e) { - FMLLog.getLogger() - .error("[GuideNH] [GuideOnStartup] Failed to open guide '{}'", showOnStartup.guideId(), e); + GuideDebugLog.error("[GuideNH] [GuideOnStartup] Failed to open guide '{}'", showOnStartup.guideId(), e); } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideRegistry.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideRegistry.java index bcab58f6..79706aa2 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideRegistry.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideRegistry.java @@ -15,8 +15,7 @@ import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; import com.hfstudio.guidenh.guide.navigation.NavigationTree; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; /** * Internal registry for Guides. @@ -104,11 +103,10 @@ public static void setDataDriven(Map guides) { dataDrivenGuides.clear(); dataDrivenGuides.putAll(guides); if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideRegistry] Replaced {} data-driven guides with {} freshly loaded guides", - previousCount, - dataDrivenGuides.size()); + GuideDebugLog.infoAlways( + "[GuideNH] [GuideRegistry] Replaced {} data-driven guides with {} freshly loaded guides", + previousCount, + dataDrivenGuides.size()); } rebuildGuides(); @@ -149,10 +147,9 @@ public static void rebuildGuides() { if (!overridden.isEmpty()) { overridden.sort(Comparator.comparing(ResourceLocation::toString)); if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideRegistry] The following guides are overridden in resource packs: {}", - overridden); + GuideDebugLog.infoAlways( + "[GuideNH] [GuideRegistry] The following guides are overridden in resource packs: {}", + overridden); } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScopedView.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScopedView.java index 3f2def60..4eba9196 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScopedView.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScopedView.java @@ -25,8 +25,7 @@ import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialDataIndex; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialDataIndexer; import com.hfstudio.guidenh.guide.navigation.NavigationTree; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class GuideScopedView implements Guide, MediaWikiListContextProvider { @@ -145,11 +144,10 @@ public boolean isPageFailed(ResourceLocation pageId) { long startNanos = System.nanoTime(); mediaWikiListContext = createFallbackMediaWikiListContext(); if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideScopedView] Built preview MediaWikiListContext in {} ms for guide {}", - nanosToMillis(System.nanoTime() - startNanos), - delegate.getId()); + GuideDebugLog.infoAlways( + "[GuideNH] [GuideScopedView] Built preview MediaWikiListContext in {} ms for guide {}", + nanosToMillis(System.nanoTime() - startNanos), + delegate.getId()); } return mediaWikiListContext; } 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 afc2e228..dbdca2f4 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreen.java @@ -46,6 +46,7 @@ import org.lwjgl.opengl.GL11; import com.github.bsideup.jabel.Desugar; +import com.hfstudio.guidenh.ClientProxy; import com.hfstudio.guidenh.client.command.GuideNhClientBridgeController; import com.hfstudio.guidenh.client.hotkey.OpenGuideHotkey; import com.hfstudio.guidenh.config.ModConfig; @@ -54,6 +55,7 @@ import com.hfstudio.guidenh.guide.GuidePage; import com.hfstudio.guidenh.guide.GuidePageIcon; import com.hfstudio.guidenh.guide.PageAnchor; +import com.hfstudio.guidenh.guide.color.Colors; import com.hfstudio.guidenh.guide.color.LightDarkMode; import com.hfstudio.guidenh.guide.color.SymbolicColor; import com.hfstudio.guidenh.guide.compiler.AnchorIndexer; @@ -104,6 +106,8 @@ import com.hfstudio.guidenh.guide.internal.home.HomePageController; import com.hfstudio.guidenh.guide.internal.home.HomePageDataBuilder; import com.hfstudio.guidenh.guide.internal.home.HomePageLayout; +import com.hfstudio.guidenh.guide.internal.host.LytHost; +import com.hfstudio.guidenh.guide.internal.host.NavigationState; import com.hfstudio.guidenh.guide.internal.item.RegionWandItem; import com.hfstudio.guidenh.guide.internal.markdown.CodeBlockClipboardService; import com.hfstudio.guidenh.guide.internal.screen.GuideIconButton; @@ -129,14 +133,15 @@ import com.hfstudio.guidenh.guide.navigation.NavigationTree; import com.hfstudio.guidenh.guide.render.VanillaRenderContext; import com.hfstudio.guidenh.guide.scene.LytGuidebookScene; +import com.hfstudio.guidenh.guide.scene.annotation.DiamondAnnotation; import com.hfstudio.guidenh.guide.scene.support.GuideBlockDisplayResolver; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.guide.scene.support.GuideEntityDisplayResolver; import com.hfstudio.guidenh.guide.sound.GuideSoundPlayback; import com.hfstudio.guidenh.guide.ui.GuideUiHost; import com.hfstudio.guidenh.integration.nei.GuideScreenNeiBridge; import com.hfstudio.guidenh.libs.unist.UnistPoint; -import cpw.mods.fml.common.FMLLog; import cpw.mods.fml.common.Loader; public class GuideScreen extends GuiContainer @@ -182,6 +187,7 @@ public class GuideScreen extends GuiContainer private final Deque forwardHistory = new ArrayDeque<>(); private int scrollY; + private float visualScrollY; private boolean pendingAnchorScroll; private float currentZoom = 1.0f; private float currentVisualScale = 1.0f; @@ -220,6 +226,10 @@ public class GuideScreen extends GuiContainer private static final int GUIDE_EDITOR_TOOLBAR_H = 16; private static final int GUIDE_EDITOR_MIN_SPLIT_PANE_W = 15; private static final int SCROLLBAR_W = SceneEditorMultilineTextArea.SCROLLBAR_SIZE; + private static final int SCROLLBAR_OUTLINE_LABEL_MAX_WIDTH = 132; + private static final int SCROLLBAR_OUTLINE_LABEL_PADDING_X = 5; + private static final int SCROLLBAR_OUTLINE_LABEL_PADDING_Y = 4; + private static final int SCROLLBAR_OUTLINE_LABEL_GAP = 6; private static final int GUIDE_EDITOR_DIVIDER_HOVER_DELAY_MILLIS = 1000; private static final long GUIDE_EDITOR_SAFETY_AUTOSAVE_INTERVAL_MILLIS = 5L * 60L * 1000L; private static final long GUIDE_EDITOR_NAVIGATION_REFRESH_DELAY_MILLIS = 1500L; @@ -238,6 +248,7 @@ public class GuideScreen extends GuiContainer private final MinecraftFontMetrics layoutFontMetrics = new MinecraftFontMetrics(); private final CodeBlockClipboardService codeBlockClipboardService = new CodeBlockClipboardService(); private final GuideDebugOverlayRenderer debugOverlayRenderer = new GuideDebugOverlayRenderer(); + private final GuideScreenScrollbarOutline scrollbarOutline = new GuideScreenScrollbarOutline(); private final GuideScreenEditorFileStore guideEditorFileStore = GuideScreenEditorFileStore.createDefault(); private final Map guideEditorActionButtons = new LinkedHashMap<>(); @@ -377,6 +388,7 @@ public class GuideScreen extends GuiContainer private long guideLastMouseEvent; private int guideTouchValue; private boolean temporaryScreenChangeExpected; + private long lastVisualUpdateNanos; public static class SceneButtonHit { @@ -496,7 +508,20 @@ private GuideScreen(GuideScreenRoute route, @Nullable GuideScreenViewState resto } catch (Throwable ignored) { navBar.setPinned(false); } - navBar.restoreState(GuideScreenMemory.recallNavigationState(), bookmarkState); + navBar.restoreState( + ClientProxy.getLytHost() + .getNavigation() + .recallNavigationState(), + bookmarkState); + ClientProxy.getLytHost() + .setPreheatCompiler(pageId -> { + if (guide == null) return null; + try { + return guide.getPage(new ResourceLocation(pageId)); + } catch (Exception e) { + return null; + } + }); } public static void open(ResourceLocation guideId, @Nullable PageAnchor anchor) { @@ -508,7 +533,9 @@ public static void openFromGuideHotkey(ResourceLocation guideId, @Nullable PageA } public static void openFromHomeHotkey() { - GuideScreenViewState remembered = GuideScreenMemory.consumeValidLastContentState(); + GuideScreenViewState remembered = ClientProxy.getLytHost() + .getNavigation() + .consumeValidLastContentState(); open(remembered != null ? remembered : GuideScreenViewState.home(), false); } @@ -541,11 +568,13 @@ private static GuideScreenViewState contentState(ResourceLocation guideId, @Null private static GuideScreenRoute contentRoute(ResourceLocation guideId, @Nullable PageAnchor anchor) { MutableGuide guide = GuideRegistry.getById(guideId); if (guide == null) { - FMLLog.warning("GuideScreen.open: no guide registered with id {}", guideId); + GuideDebugLog.warnAlways("GuideScreen.open: no guide registered with id {}", guideId); return null; } if (anchor == null) { - GuideScreenViewState remembered = GuideScreenMemory.consumeValidLastContentState(); + GuideScreenViewState remembered = ClientProxy.getLytHost() + .getNavigation() + .consumeValidLastContentState(); if (remembered != null && remembered.route() != null) { return remembered.route(); } @@ -612,12 +641,20 @@ public void reloadPage() { currentPage = null; document = null; lastLayoutWidth = -1; + invalidateScrollbarOutline(); + if (currentAnchor != null) { + ClientProxy.getLytHost() + .invalidatePage( + currentAnchor.pageId() + .toString()); + } loadCurrentPage(); updateToolbarButtonState(); } @Override public void initGui() { + GuideScreenNeiBridge.ensureManagerInitialized(this); Keyboard.enableRepeatEvents(true); syncGuideEditorStateFromConfig(); if (document == null) { @@ -672,8 +709,11 @@ private void restoreViewState(GuideScreenViewState state) { document = null; layoutDocument = null; lastLayoutWidth = -1; + invalidateScrollbarOutline(); scrollY = 0; + snapVisualScrollToTarget(); loadCurrentPage(); + expandNavigationParentsToCurrentPage(); ensureLayout(); scrollToCurrentAnchor(); applyPendingRestoreScroll(); @@ -683,6 +723,13 @@ private void restoreViewState(GuideScreenViewState state) { } } + private void expandNavigationParentsToCurrentPage() { + if (!hasContentRoute()) { + return; + } + navBar.expandParentsTo(resolveNavigationTree(), currentAnchor.pageId(), bookmarkState); + } + private void applyPendingRestoreScroll() { if (pendingRestoreViewState == null || !pendingRestoreViewState.route() .equals(currentRoute)) { @@ -691,6 +738,7 @@ private void applyPendingRestoreScroll() { scrollY = pendingRestoreViewState.scrollY(); pendingRestoreViewState = null; clampScroll(); + snapVisualScrollToTarget(); } private void finalizePendingViewState() { @@ -702,11 +750,16 @@ private void finalizePendingViewState() { } private void rememberCurrentContentStateIfEligible() { - GuideScreenMemory.rememberContentState(captureCurrentViewState()); + ClientProxy.getLytHost() + .getNavigation() + .rememberContentState(captureCurrentViewState()); } private void rememberNavigationState() { - GuideScreenMemory.rememberNavigationState(navBar.captureState()); + if (guide == null) return; + ClientProxy.getLytHost() + .getNavigation() + .rememberNavBarState(guide.getId(), navBar.captureState()); } private boolean isNavigationNewPageButtonVisible() { @@ -726,6 +779,7 @@ private void toggleNavigationPinned() { rebuildToolbar(); layoutDocument = null; lastLayoutWidth = -1; + invalidateScrollbarOutline(); ensureLayout(); clampScroll(); } @@ -753,6 +807,15 @@ private NavigationTree resolveNavigationTree() { @Override public void updateScreen() { + if (mc == null) return; + try { + tickScreen(); + } catch (NullPointerException ignored) { + // NEI Mixin may leave GuiContainer.manager uninitialized for GuideScreen + } + } + + private void tickScreen() { completePendingContentPageLoadIfNeeded(); processPendingSceneRegistrations(); GuideScreenNeiBridge.tick(this); @@ -1045,7 +1108,9 @@ private MutableGuide resolveGuideEditorTargetGuide() { if (guide != null) { return guide; } - GuideScreenViewState remembered = GuideScreenMemory.recallLastContentState(); + GuideScreenViewState remembered = ClientProxy.getLytHost() + .getNavigation() + .recallLastContentState(); if (remembered != null && remembered.route() != null) { ResourceLocation rememberedGuideId = remembered.route() .guideId(); @@ -1077,7 +1142,8 @@ private void createGuideEditorPageAtPath(MutableGuide activeGuide, ParsedGuidePa Path sourceRoot = guideEditorFileStore .findWritablePageResourcePackRoot(activeGuide, currentParsedPage.getId(), language); if (sourceRoot == null) { - FMLLog.warning("Failed to create guide editor page because current page has no writable resource pack"); + GuideDebugLog + .warnAlways("Failed to create guide editor page because current page has no writable resource pack"); return; } @@ -1094,7 +1160,7 @@ private void createGuideEditorPageAtPath(MutableGuide activeGuide, ParsedGuidePa refreshGuideEditorDraft(true); rebuildToolbar(); } catch (Throwable t) { - FMLLog.warning("Failed to create guide editor page from path {}", requestedPath, t); + GuideDebugLog.warnAlways("Failed to create guide editor page from path {}", requestedPath, t); } } @@ -1366,6 +1432,8 @@ private void rebuildGuideEditorPreview() { .compile(buildGuideEditorPreviewGuide(parsedDraft), guide.getExtensions(), parsedDraft); int previewWidth = getGuideEditorPreviewLayoutWidth(); if (guideEditorPreviewPage != null && guideEditorPreviewPage.document() != null) { + ClientProxy.getLytHost() + .dispatchToSubtree(guideEditorPreviewPage.document()); guideEditorPreviewPage.document() .updateLayout( createLayoutContext(previewWidth, getVisualReferenceContentWidth()), @@ -1375,12 +1443,16 @@ private void rebuildGuideEditorPreview() { resolveVisualScale(getVisualReferenceContentWidth(), previewWidth)); } guideEditorPreviewDirty = false; + ClientProxy.getLytHost() + .invalidatePage( + currentAnchor.pageId() + .toString()); if (canApplyGuideEditorParsedPage(parsedDraft)) { guideEditorDraftPage = parsedDraft; } cachedGuideEditorPreviewInteractionState = null; } catch (Throwable t) { - FMLLog.warning("Failed to compile guide editor preview for {}", currentAnchor.pageId(), t); + GuideDebugLog.warnAlways("Failed to compile guide editor preview for {}", currentAnchor.pageId(), t); } } @@ -1511,19 +1583,18 @@ private boolean saveGuideEditorDraft() { } updateToolbarButtonState(); if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideScreen] Saved guide editor draft for {} in {} ms (write: {} ms, parse: {} ms, stage: {} ms, reusedParsed={})", - currentAnchor.pageId(), - (System.nanoTime() - startedAt) / 1_000_000L, - saveFileNs / 1_000_000L, - parseNs / 1_000_000L, - stagePageApplyNs / 1_000_000L, - reusedGuideEditorParsedDraft(sourcePack, language)); + GuideDebugLog.infoAlways( + "[GuideNH] [GuideScreen] Saved guide editor draft for {} in {} ms (write: {} ms, parse: {} ms, stage: {} ms, reusedParsed={})", + currentAnchor.pageId(), + (System.nanoTime() - startedAt) / 1_000_000L, + saveFileNs / 1_000_000L, + parseNs / 1_000_000L, + stagePageApplyNs / 1_000_000L, + reusedGuideEditorParsedDraft(sourcePack, language)); } return true; } catch (Throwable t) { - FMLLog.warning("Failed to autosave guide editor page {}", currentAnchor.pageId(), t); + GuideDebugLog.warnAlways("Failed to autosave guide editor page {}", currentAnchor.pageId(), t); return false; } } @@ -1632,7 +1703,7 @@ private void refreshGuideEditorPreviewState() { scheduleGuideEditorNavigationRefresh(); } } catch (Throwable t) { - FMLLog.warning("Failed to refresh guide editor draft state for {}", currentAnchor.pageId(), t); + GuideDebugLog.warnAlways("Failed to refresh guide editor draft state for {}", currentAnchor.pageId(), t); } } @@ -1733,7 +1804,7 @@ private void updateGuideEditorNavigationRefresh() { GuideME.getSearch() .index(guide); } catch (Throwable t) { - FMLLog.warning("Guide editor navigation refresh failed", t); + GuideDebugLog.warnAlways("Guide editor navigation refresh failed", t); } } @@ -2059,25 +2130,9 @@ protected void actionPerformed(GuiButton btn) { if (btn == btnClose) { close(); } else if (btn == btnBack) { - if (!history.isEmpty()) { - confirmGuideEditorDirtyBefore(() -> { - rememberCurrentContentStateIfEligible(); - forwardHistory.push(captureCurrentViewState()); - var prev = history.pop(); - restoreViewState(prev); - rebuildToolbar(); - }); - } + navigateBackInHistory(); } else if (btn == btnForward) { - if (!forwardHistory.isEmpty()) { - confirmGuideEditorDirtyBefore(() -> { - rememberCurrentContentStateIfEligible(); - history.push(captureCurrentViewState()); - var next = forwardHistory.pop(); - restoreViewState(next); - rebuildToolbar(); - }); - } + navigateForwardInHistory(); } else if (btn == btnFullWidth) { fullWidth = !fullWidth; try { @@ -2134,6 +2189,40 @@ protected void actionPerformed(GuiButton btn) { } } + public void navigateBackFromHotkey() { + navigateBackInHistory(); + } + + public void navigateForwardFromHotkey() { + navigateForwardInHistory(); + } + + private void navigateBackInHistory() { + if (history.isEmpty()) { + return; + } + confirmGuideEditorDirtyBefore(() -> { + rememberCurrentContentStateIfEligible(); + forwardHistory.push(captureCurrentViewState()); + var prev = history.pop(); + restoreViewState(prev); + rebuildToolbar(); + }); + } + + private void navigateForwardInHistory() { + if (forwardHistory.isEmpty()) { + return; + } + confirmGuideEditorDirtyBefore(() -> { + rememberCurrentContentStateIfEligible(); + history.push(captureCurrentViewState()); + var next = forwardHistory.pop(); + restoreViewState(next); + rebuildToolbar(); + }); + } + private void loadCurrentPage() { clearInteractionState(); closeTransientContextMenus(); @@ -2245,9 +2334,15 @@ private void resetPendingSceneRegistrations() { } private void tickCurrentPageScenes() { - if (currentPage == null || !pendingSceneRegistrations.isEmpty()) { + if (currentPage == null) { + return; + } + if (!pendingSceneRegistrations.isEmpty()) { return; } + // Phase 3: Scenes created asynchronously (MaterializeTask) appear in the document + // tree after mountDocument returns. Scan for new scenes each tick. + registerRuntimeScenes(currentPage); for (LytGuidebookScene scene : currentPage.scenes()) { scene.ponderTick(); } @@ -2271,20 +2366,41 @@ private void completePendingContentPageLoadIfNeeded() { return; } int requestId = pendingPageLoadRequestId; - GuidePage loadedPage = null; - try { - loadedPage = guide.getPage(currentAnchor.pageId()); - } catch (Throwable t) { - FMLLog.severe("Failed to compile guide page {}", currentAnchor.pageId(), t); + String pageIdStr = currentAnchor.pageId() + .toString(); + LytHost lytHost = ClientProxy.getLytHost(); + GuidePage loadedPage; + + GuidePage cachedPage = lytHost.getCachedGuidePage(pageIdStr); + if (cachedPage != null) { + loadedPage = cachedPage; + loadedPage.prepareForDisplay(); + } else { + try { + loadedPage = guide.getPage(currentAnchor.pageId()); + } catch (Throwable t) { + GuideDebugLog.error("Failed to compile guide page {}", currentAnchor.pageId(), t); + loadedPage = null; + } + if (loadedPage != null) { + lytHost.cachePage(pageIdStr, loadedPage); + } } if (!pageLoadInProgress || requestId != pendingPageLoadRequestId) { return; } currentPage = loadedPage; document = loadedPage != null ? loadedPage.document() : null; + invalidateScrollbarOutline(); + lytHost.setCurrentPageId(pageIdStr); + lytHost.setCurrentPageCollection(guide); + lytHost.mountDocument(document); + lytHost.requestPreheatNeighbors(pageIdStr); if (document != null && isSpecialPageWithSearchField()) { applySpecialPageSearchQuery(queryFromCurrentAnchor()); } + ensureLayout(); + scrollToCurrentAnchor(); syncSearchFieldToCurrentRoute(); if (loadedPage != null) { queuePageSceneRegistrations(loadedPage); @@ -2294,6 +2410,33 @@ private void completePendingContentPageLoadIfNeeded() { updateToolbarButtonState(); } + /** Register scenes created at MOUNT time into GuidePage.scenes() for tick dispatch. */ + private static void registerRuntimeScenes(GuidePage page) { + LytDocument doc = page.document(); + if (doc == null) return; + java.util.List list = page.scenes(); + java.util.ArrayDeque pending = new java.util.ArrayDeque<>(); + pending.add(doc); + int found = 0; + while (!pending.isEmpty()) { + LytNode node = pending.removeLast(); + if (node instanceof LytGuidebookScene scene && !list.contains(scene)) { + list.add(scene); + found++; + } + var children = node.getChildren(); + for (int i = children.size() - 1; i >= 0; i--) { + pending.addLast(children.get(i)); + } + } + if (found > 0) { + GuideDebugLog.infoAlways( + "[PonderDebug] registerRuntimeScenes: registered {} new scenes, total={}", + found, + list.size()); + } + } + private void tickGuideEditorPreviewScenes() { if (!isGuideEditorActive() || guideEditorPreviewPage == null) { return; @@ -2342,6 +2485,7 @@ private void ensureLayout() { layoutDocument = activeDocument; lastLayoutWidth = layoutWidth; lastLayoutVisualScalePermille = layoutVisualScalePermille; + invalidateScrollbarOutline(); } } @@ -2350,21 +2494,23 @@ private void scrollToCurrentAnchor() { if (!pendingAnchorScroll) return; if (currentAnchor == null || currentAnchor.anchor() == null) return; if (document == null) return; - pendingAnchorScroll = false; var target = new AnchorIndexer(document).get(currentAnchor.anchor()); if (target == null) return; + pendingAnchorScroll = false; var blockNode = target.blockNode(); var flowContent = target.flowContent(); if (flowContent instanceof LytFlowAnchor flowAnchor) { var layoutY = flowAnchor.getLayoutY(); if (layoutY.isPresent()) { scrollY = layoutY.getAsInt(); + snapVisualScrollToTarget(); return; } } LytRect bounds = blockNode.getBounds(); if (bounds != null) { scrollY = bounds.y(); + snapVisualScrollToTarget(); } } @@ -2454,6 +2600,42 @@ private void clampScroll() { int max = getMaxScroll(); if (scrollY < 0) scrollY = 0; if (scrollY > max) scrollY = max; + if (Math.abs(visualScrollY - scrollY) > Math.max(96f, getDocumentViewportHeight() * 2f)) { + visualScrollY = scrollY; + } + ClientProxy.getLytHost() + .getViewport() + .updateContent(contentW, contentH); + ClientProxy.getLytHost() + .getViewport() + .scrollTo(scrollY); + } + + private void snapVisualScrollToTarget() { + visualScrollY = scrollY; + } + + private void updateVisualState() { + long now = System.nanoTime(); + if (lastVisualUpdateNanos == 0L) { + lastVisualUpdateNanos = now; + visualScrollY = scrollY; + return; + } + float deltaSeconds = Math.min(0.05f, (now - lastVisualUpdateNanos) / 1_000_000_000f); + lastVisualUpdateNanos = now; + visualScrollY = smoothApproach(visualScrollY, scrollY, deltaSeconds, 28f); + if (Math.abs(visualScrollY - scrollY) < 0.01f) { + visualScrollY = scrollY; + } + } + + private static float smoothApproach(float current, float target, float deltaSeconds, float sharpness) { + if (deltaSeconds <= 0f) { + return current; + } + float blend = 1f - (float) Math.exp(-sharpness * deltaSeconds); + return current + (target - current) * blend; } @Override @@ -2461,6 +2643,7 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { lastMouseX = mouseX; lastMouseY = mouseY; hoveredItemStack = null; + updateVisualState(); drawTiledBackground(); recomputePanelBounds(); if (consumePanelBoundsChanged()) { @@ -2473,6 +2656,8 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { currentZoom = resolveCurrentZoom(); ensureLayout(); clampScroll(); + pollContinuousMouseDrag(mouseX, mouseY); + updateScrollbarOutlineHover(mouseX, mouseY); int navX = panelX; int navY = panelY + TOOLBAR_H + 1; @@ -2597,7 +2782,7 @@ private void drawHomeContent(int mouseX, int mouseY) { } var sections = homePageDataBuilder.build(bookmarkState, homeHistory); var layout = HomePageLayout.compute(contentX, contentY, contentW, contentH, homeLogoWidth, homeLogoHeight); - homePageController.render(mc, sections, layout, logoTexture, mouseX, mouseY); + homePageController.render(mc, sections, layout, logoTexture, homeLogoWidth, homeLogoHeight, mouseX, mouseY); } @Nullable @@ -2608,12 +2793,12 @@ private static ResourceLocation getHomeLogoTexture() { try (InputStream inputStream = GuideScreen.class.getResourceAsStream(HOME_LOGO_RESOURCE_PATH)) { if (inputStream == null) { - FMLLog.warning("GuideScreen home logo resource not found at {}", HOME_LOGO_RESOURCE_PATH); + GuideDebugLog.warnAlways("GuideScreen home logo resource not found at {}", HOME_LOGO_RESOURCE_PATH); return null; } BufferedImage image = ImageIO.read(inputStream); if (image == null) { - FMLLog.warning("GuideScreen home logo failed to decode at {}", HOME_LOGO_RESOURCE_PATH); + GuideDebugLog.warnAlways("GuideScreen home logo failed to decode at {}", HOME_LOGO_RESOURCE_PATH); return null; } homeLogoWidth = image.getWidth(); @@ -2623,7 +2808,7 @@ private static ResourceLocation getHomeLogoTexture() { .getDynamicTextureLocation(HOME_LOGO_SOURCE.getResourcePath(), new DynamicTexture(image)); return homeLogoTexture; } catch (Exception e) { - FMLLog.warning("GuideScreen failed to load home logo from {}", HOME_LOGO_RESOURCE_PATH, e); + GuideDebugLog.warnAlways("GuideScreen failed to load home logo from {}", HOME_LOGO_RESOURCE_PATH, e); return null; } } @@ -2634,6 +2819,17 @@ private void pollActiveSceneDrag() { } } + private void pollContinuousMouseDrag(int mouseX, int mouseY) { + if (guideMouseEventButton < 0 || guideLastMouseEvent <= 0L) { + return; + } + if (guideMouseEventButton <= 2 && !Mouse.isButtonDown(guideMouseEventButton)) { + return; + } + long heldTime = Minecraft.getSystemTime() - guideLastMouseEvent; + mouseClickMove(mouseX, mouseY, guideMouseEventButton, heldTime); + } + private void drawGuideEditorScreen(int mouseX, int mouseY) { ensureGuideEditorTextArea(); updateGuideEditorTextFromArea(); @@ -2835,7 +3031,7 @@ private void renderGuideEditorPreview(int x, int y, int width, int height) { try { previewDocument.render(reusableRenderCtx); } catch (Throwable t) { - FMLLog.warning("Failed to render guide editor preview", t); + GuideDebugLog.warnAlways("Failed to render guide editor preview", t); } finally { GL11.glPopMatrix(); reusableRenderCtx.restoreExternalRenderState(); @@ -3941,7 +4137,7 @@ private void drawContentTooltip(ContentTooltip ct, int mouseX, int mouseY, ct.getContent() .render(ctx); } catch (Throwable t) { - FMLLog.warning("Error rendering ContentTooltip", t); + GuideDebugLog.warnAlways("Error rendering ContentTooltip", t); } finally { GL11.glPopMatrix(); ctx.restoreExternalRenderState(); @@ -4136,14 +4332,16 @@ private void renderDocument(int mouseX, int mouseY) { var ctx = reusableRenderCtx; ctx.setLightDarkMode(LightDarkMode.LIGHT_MODE); int documentRenderOffsetY = getDocumentRenderOffsetY(activeDocument); - int viewportTopInDocument = Math.max(0, scrollY - documentRenderOffsetY); + int renderedScrollY = Math.round(visualScrollY); + int viewportTopInDocument = Math.max(0, renderedScrollY - documentRenderOffsetY); cachedViewportRect = cachedRect(cachedViewportRect, 0, viewportTopInDocument, contentW, documentH); cachedScissorRect = cachedRect(cachedScissorRect, contentX, documentY, contentW, documentH); ctx.setViewport(cachedViewportRect); ctx.setScreenHeight(this.height); int documentRenderY = getDocumentViewportY() + documentRenderOffsetY; ctx.setDocumentOrigin(contentX, documentRenderY); - ctx.setScrollOffsetY(scrollY); + ctx.setScrollOffsetY(renderedScrollY); + ctx.setPreciseScrollOffsetY(visualScrollY); ctx.setZoom(currentZoom); ctx.pushScissor(cachedScissorRect); GL11.glPushMatrix(); @@ -4151,11 +4349,11 @@ private void renderDocument(int mouseX, int mouseY) { if (currentZoom != 1.0f) { GL11.glScalef(currentZoom, currentZoom, 1f); } - GL11.glTranslatef(0f, -(float) scrollY, 0f); + GL11.glTranslatef(0f, -visualScrollY, 0f); try { activeDocument.render(ctx); } catch (Throwable t) { - FMLLog.severe("Error rendering guide document {}", currentAnchor.pageId(), t); + GuideDebugLog.error("Error rendering guide document {}", currentAnchor.pageId(), t); } finally { GL11.glPopMatrix(); ctx.restoreExternalRenderState(); @@ -4266,7 +4464,7 @@ public static LytRect cachedRect(@Nullable LytRect current, int x, int y, int w, } private void drawPageMissingMessage() { - if (isHomeRoute()) { + if (isHomeRoute() || mc == null) { return; } FontRenderer fr = mc.fontRenderer; @@ -4276,6 +4474,7 @@ private void drawPageMissingMessage() { } private void drawLoadingMessage() { + if (mc == null) return; FontRenderer fr = mc.fontRenderer; String message = buildAnimatedLoadingLabel(GuidebookText.SceneLoading.text()); int tw = fr.getStringWidth(message); @@ -4321,6 +4520,10 @@ 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"); + return; + } mc.getTextureManager() .bindTexture(BG_TEXTURE); GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_WRAP_S, GL11.GL_REPEAT); @@ -4349,31 +4552,42 @@ private void drawBorder(int x, int y, int w, int h, int color) { } private void drawScrollbar() { - int barW = SCROLLBAR_W; - int barX = panelX + panelW - 1 - barW; - int barY = getDocumentViewportY(); - int barH = getDocumentViewportHeight(); - drawRect(barX, barY, barX + barW, barY + barH, 0x40FFFFFF); - - int total = getContentHeight(); - int viewportH = getDocumentViewportHeight(); - int thumbH = Math.max(16, (int) ((long) barH * viewportH / Math.max(1, total))); - int maxScroll = getMaxScroll(); - int thumbY = maxScroll > 0 ? barY + (int) ((long) (barH - thumbH) * scrollY / maxScroll) : barY; + var bounds = scrollbarBounds(); + drawRect(bounds.x(), bounds.y(), bounds.x() + bounds.width(), bounds.y() + bounds.height(), 0x40FFFFFF); + var renderState = scrollbarOutline.update( + currentPage, + getActiveDocument(), + bounds, + currentZoom, + Math.round(visualScrollY), + lastMouseX, + lastMouseY, + System.currentTimeMillis(), + mc.fontRenderer, + SCROLLBAR_OUTLINE_LABEL_MAX_WIDTH, + SCROLLBAR_OUTLINE_LABEL_GAP, + panelX, + panelX + panelW); + drawScrollbarOutlineMarkers(renderState); + + int thumbH = Math + .max(16, (int) ((long) bounds.height() * bounds.viewportHeight() / Math.max(1, bounds.contentHeight()))); + int thumbY = bounds.maxScroll() > 0 + ? bounds.y() + (int) ((long) (bounds.height() - thumbH) * Math.round(visualScrollY) / bounds.maxScroll()) + : bounds.y(); int thumbColor = draggingScrollbar ? 0xFFFFFFFF : 0xFFCCCCCC; - drawRect(barX, thumbY, barX + barW, thumbY + thumbH, thumbColor); + drawRect(bounds.x(), thumbY, bounds.x() + bounds.width(), thumbY + thumbH, thumbColor); + drawScrollbarOutlineLabel(renderState, mc.fontRenderer); } private int[] scrollbarThumbRect() { - int barX = panelX + panelW - 1 - SCROLLBAR_W; - int barY = getDocumentViewportY(); - int barH = getDocumentViewportHeight(); - int total = getContentHeight(); - int viewportH = getDocumentViewportHeight(); - int thumbH = Math.max(16, (int) ((long) barH * viewportH / Math.max(1, total))); - int maxScroll = getMaxScroll(); - int thumbY = maxScroll > 0 ? barY + (int) ((long) (barH - thumbH) * scrollY / maxScroll) : barY; - return new int[] { barX, thumbY, SCROLLBAR_W, thumbH, barY, barH }; + var bounds = scrollbarBounds(); + int thumbH = Math + .max(16, (int) ((long) bounds.height() * bounds.viewportHeight() / Math.max(1, bounds.contentHeight()))); + int thumbY = bounds.maxScroll() > 0 + ? bounds.y() + (int) ((long) (bounds.height() - thumbH) * Math.round(visualScrollY) / bounds.maxScroll()) + : bounds.y(); + return new int[] { bounds.x(), thumbY, bounds.width(), thumbH, bounds.y(), bounds.height() }; } private void updateScrollFromMouseY(int mouseY) { @@ -4484,6 +4698,7 @@ protected void drawGuiContainerBackgroundLayer(float partialTicks, int mouseX, i @Override public void handleKeyboardInput() { + if (mc == null) return; if (Keyboard.getEventKeyState() || isCommittedCharacterEventForFocusedTextInput()) { keyTyped(Keyboard.getEventCharacter(), Keyboard.getEventKey()); } @@ -4561,6 +4776,9 @@ protected void mouseClicked(int mouseX, int mouseY, int button) { return; } if (result != null && result.bookmarkTogglePageId() != null) { + ClientProxy.getLytHost() + .getNavigation() + .toggleBookmark(result.bookmarkTogglePageId()); bookmarkState.toggle(result.bookmarkTogglePageId()); mc.getSoundHandler() .playSound(PositionedSoundRecord.func_147674_a(new ResourceLocation("gui.button.press"), 1.0F)); @@ -4635,6 +4853,13 @@ protected void mouseClicked(int mouseX, int mouseY, int button) { draggingScrollbar = true; return; } + Integer markerTarget = scrollbarOutline.findJumpTarget(mouseX, mouseY); + if (markerTarget != null) { + scrollY = markerTarget.intValue(); + clampScroll(); + snapVisualScrollToTarget(); + return; + } } var activeDocument = getActiveDocument(); if (activeDocument != null && isInsideDocument(mouseX, mouseY)) { @@ -5139,7 +5364,7 @@ private void openDirectory(Path directory) { return; } } catch (Exception e) { - FMLLog.warning("Failed to open guide directory {}", directory, e); + GuideDebugLog.warnAlways("Failed to open guide directory {}", directory, e); } tryOpenDirectoryWithCommand(directory); } @@ -5176,7 +5401,7 @@ private void revealFileTarget(Path fileTarget) { new ProcessBuilder(command).start(); return; } catch (Exception e) { - FMLLog.warning("Failed to reveal guide file {}", fileTarget, e); + GuideDebugLog.warnAlways("Failed to reveal guide file {}", fileTarget, e); } } Path parent = fileTarget.getParent(); @@ -5191,7 +5416,7 @@ private void revealFileTarget(Path fileTarget) { .open(fileTarget.toFile()); } } catch (Exception e) { - FMLLog.warning("Failed to open guide file {}", fileTarget, e); + GuideDebugLog.warnAlways("Failed to open guide file {}", fileTarget, e); } } @@ -5220,7 +5445,7 @@ private void tryOpenDirectoryWithCommand(Path directory) { try { new ProcessBuilder(command).start(); } catch (Exception e) { - FMLLog.warning("Failed to open guide directory {}", directory, e); + GuideDebugLog.warnAlways("Failed to open guide directory {}", directory, e); } } @@ -5511,11 +5736,11 @@ private DocumentInteractionState getDocumentInteractionState(int mouseX, int mou getDocumentRenderY(activeDocument), contentW, getDocumentViewportHeight(), - scrollY)) { + Math.round(visualScrollY))) { return interaction; } int docX = Math.round((mouseX - contentX) / currentZoom); - int docY = Math.round((mouseY - getDocumentRenderY(activeDocument)) / currentZoom) + scrollY; + int docY = Math.round((mouseY - getDocumentRenderY(activeDocument)) / currentZoom) + Math.round(visualScrollY); var hit = activeDocument.pick(docX, docY); var scene = hit != null ? findSceneAncestor(hit.node()) : null; SceneButtonHit sceneButtonHit = null; @@ -5541,7 +5766,7 @@ private DocumentInteractionState getDocumentInteractionState(int mouseX, int mou getDocumentRenderY(activeDocument), contentW, getDocumentViewportHeight(), - scrollY, + Math.round(visualScrollY), contentX, getDocumentViewportY(), contentW, @@ -5590,10 +5815,10 @@ private int[] screenToDocumentPoint(int mouseX, int mouseY) { var activeDocument = getActiveDocument(); if (activeDocument == null) { return new int[] { Math.round((mouseX - contentX) / currentZoom), - Math.round((mouseY - getDocumentViewportY()) / currentZoom) + scrollY }; + Math.round((mouseY - getDocumentViewportY()) / currentZoom) + Math.round(visualScrollY) }; } return new int[] { Math.round((mouseX - contentX) / currentZoom), - Math.round((mouseY - getDocumentRenderY(activeDocument)) / currentZoom) + scrollY }; + Math.round((mouseY - getDocumentRenderY(activeDocument)) / currentZoom) + Math.round(visualScrollY) }; } private int[] screenToActiveDocumentPoint(int mouseX, int mouseY) { @@ -5636,6 +5861,7 @@ public static boolean isShiftDown() { @Override protected void keyTyped(char typedChar, int keyCode) { + if (handleDebugHudToggleKey(keyCode)) return; if (GuideScreenNeiBridge.keyTyped(this, typedChar, keyCode)) return; if (handleSearchFieldKey(typedChar, keyCode)) return; if (handleSpecialSearchFieldKey(typedChar, keyCode)) return; @@ -5662,6 +5888,7 @@ protected void keyTyped(char typedChar, int keyCode) { } if (keyCode == Keyboard.KEY_HOME) { scrollY = 0; + snapVisualScrollToTarget(); return; } if (keyCode == Keyboard.KEY_END) { @@ -5681,6 +5908,14 @@ protected void keyTyped(char typedChar, int keyCode) { super.keyTyped(typedChar, keyCode); } + private boolean handleDebugHudToggleKey(int keyCode) { + if (keyCode != Keyboard.KEY_F3 || !ModConfig.debug.enableDebugMode || mc == null || mc.gameSettings == null) { + return false; + } + mc.gameSettings.showDebugInfo = !mc.gameSettings.showDebugInfo; + return true; + } + private boolean isInsideDocument(int mouseX, int mouseY) { if (isInsideSpecialSearchField(mouseX, mouseY)) { return false; @@ -5830,7 +6065,7 @@ public boolean copyCodeBlock(String text) { codeBlockClipboardService.copy(text); return true; } catch (Exception e) { - FMLLog.severe("Failed to copy code block", e); + GuideDebugLog.error("Failed to copy code block", e); return false; } } @@ -5845,7 +6080,7 @@ private void browseExternalUrl(URI uri) { Desktop.getDesktop() .browse(uri); } catch (Exception e) { - FMLLog.warning("Failed to open external guide link {}", uri, e); + GuideDebugLog.warnAlways("Failed to open external guide link {}", uri, e); } } @@ -6238,7 +6473,7 @@ private LytDocument buildSearchDocument(String query) { clipSnippetForWidth(result.text(), getSearchSnippetLineWidth(textColumnWidth)))); } } catch (Throwable t) { - FMLLog.warning("Search failed", t); + GuideDebugLog.warnAlways("Search failed", t); } return GuideSearchResultDocumentBuilder @@ -6448,6 +6683,8 @@ private void updateSearchQuery(String query) { refreshCurrentPageTitle(); rebuildSearchDocumentIfNeeded(true); scrollY = 0; + snapVisualScrollToTarget(); + invalidateScrollbarOutline(); rebuildToolbar(); syncSearchFieldsToCurrentRoute(); } @@ -6469,6 +6706,7 @@ private void updateSpecialPageQuery(String query) { pendingAnchorScroll = false; applySpecialPageSearchQuery(query); scrollY = 0; + snapVisualScrollToTarget(); syncSearchFieldsToCurrentRoute(); } @@ -6480,6 +6718,7 @@ private void applySpecialPageSearchQuery(String query) { clearInteractionState(); layoutDocument = null; lastLayoutWidth = -1; + invalidateScrollbarOutline(); clampScroll(); } @@ -6495,15 +6734,99 @@ private void updateSpecialSearchBlocks(LytNode root, String query) { } } + private GuideScreenScrollbarOutline.ScrollbarBounds scrollbarBounds() { + return new GuideScreenScrollbarOutline.ScrollbarBounds( + panelX + panelW - SCROLLBAR_W, + getDocumentViewportY(), + SCROLLBAR_W, + getDocumentViewportHeight(), + getContentHeight(), + getDocumentViewportHeight(), + getMaxScroll()); + } + + private void updateScrollbarOutlineHover(int mouseX, int mouseY) { + if (getMaxScroll() <= 0 || isGuideEditorActive() || isHomeRoute()) { + scrollbarOutline.clearHover(); + return; + } + scrollbarOutline.update( + currentPage, + getActiveDocument(), + scrollbarBounds(), + currentZoom, + scrollY, + mouseX, + mouseY, + System.currentTimeMillis(), + null, + SCROLLBAR_OUTLINE_LABEL_MAX_WIDTH, + SCROLLBAR_OUTLINE_LABEL_GAP, + panelX, + panelX + panelW); + } + + private void drawScrollbarOutlineMarkers(GuideScreenScrollbarOutline.RenderState renderState) { + for (int index = 0; index < renderState.entries() + .size(); index++) { + var entry = renderState.entries() + .get(index); + int color = index == renderState.activeIndex() ? withAlpha(lighten(entry.colorArgb(), 12), 220) + : withAlpha(entry.colorArgb(), 140); + drawRect( + entry.markerX(), + entry.markerY(), + entry.markerX() + entry.markerWidth(), + entry.markerY() + entry.markerHeight(), + color); + } + } + + private void drawScrollbarOutlineLabel(GuideScreenScrollbarOutline.RenderState renderState, + FontRenderer fontRenderer) { + var label = renderState.labelLayout(); + if (label == null || label.lines() + .isEmpty()) { + return; + } + int bubbleWidth = label.width() + SCROLLBAR_OUTLINE_LABEL_PADDING_X * 2; + int bubbleHeight = label.height() + SCROLLBAR_OUTLINE_LABEL_PADDING_Y * 2; + int background = Colors.argb(label.alpha(), 16, 16, 16); + int border = Colors.argb(label.alpha(), 216, 216, 216); + drawRect(label.x(), label.y(), label.x() + bubbleWidth, label.y() + bubbleHeight, background); + drawBorder(label.x(), label.y(), bubbleWidth, bubbleHeight, border); + int textY = label.y() + SCROLLBAR_OUTLINE_LABEL_PADDING_Y; + for (String line : label.lines()) { + fontRenderer.drawStringWithShadow(line, label.x() + SCROLLBAR_OUTLINE_LABEL_PADDING_X, textY, 0xFFFFFF); + textY += fontRenderer.FONT_HEIGHT; + } + } + + private int withAlpha(int argb, int alpha) { + int clampedAlpha = Math.max(0, Math.min(255, alpha)); + return (argb & 0x00FFFFFF) | (clampedAlpha << 24); + } + + private int lighten(int argb, int percent) { + return DiamondAnnotation.lighten(argb, percent); + } + + private void invalidateScrollbarOutline() { + scrollbarOutline.invalidateLayout(); + } + private void recordHomeHistoryIfEligible() { if (currentRoute == null || !currentRoute.isContent() || currentAnchor == null || currentAnchor.pageId() == null - || !GuideScreenMemory.isSupportedContentAnchor(currentAnchor) + || !NavigationState.isSupportedContentAnchor(currentAnchor) || guide == null || !guide.pageExists(currentAnchor.pageId())) { return; } + ClientProxy.getLytHost() + .getNavigation() + .recordHomeHistory(guide.getId(), currentAnchor.pageId()); homeHistory.record(guide.getId(), currentAnchor.pageId()); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenInputPolicy.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenInputPolicy.java new file mode 100644 index 00000000..4aed15ff --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenInputPolicy.java @@ -0,0 +1,19 @@ +package com.hfstudio.guidenh.guide.internal; + +public class GuideScreenInputPolicy { + + private GuideScreenInputPolicy() {} + + public static boolean shouldRouteNavigationClickBeforeEditor(boolean navigationOpen, boolean navigationContains, + int button) { + return button == 0 && navigationOpen && navigationContains; + } + + public static boolean shouldSuppressNavigationHoverForEditorMouseDown(boolean guideEditorActive, + boolean editorContainsMouse, boolean editorMouseButtonDown, boolean navigationOpen, + boolean navigationContains) { + return guideEditorActive && editorContainsMouse + && editorMouseButtonDown + && !(navigationOpen && navigationContains); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenScrollbarOutline.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenScrollbarOutline.java new file mode 100644 index 00000000..07d34ffd --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideScreenScrollbarOutline.java @@ -0,0 +1,346 @@ +package com.hfstudio.guidenh.guide.internal; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +import net.minecraft.client.gui.FontRenderer; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.GuidePage; +import com.hfstudio.guidenh.guide.color.ColorValue; +import com.hfstudio.guidenh.guide.color.LightDarkMode; +import com.hfstudio.guidenh.guide.document.DefaultStyles; +import com.hfstudio.guidenh.guide.document.block.LytDocument; +import com.hfstudio.guidenh.guide.document.block.LytHeading; +import com.hfstudio.guidenh.guide.style.ResolvedTextStyle; + +public class GuideScreenScrollbarOutline { + + public static final long HOVER_DELAY_MILLIS = 500L; + + private static final int H1_MARKER_WIDTH = 10; + private static final int H2_MARKER_WIDTH = 7; + private static final int MARKER_HEIGHT = 2; + private static final int HIT_PADDING_X = 2; + private static final int HIT_PADDING_Y = 2; + private static final int LABEL_MAX_LINES = 2; + private static final String ELLIPSIS = "..."; + + private List anchors = List.of(); + private List entries = List.of(); + private int lastLayoutSignature; + @Nullable + private HoverState hoverState; + + public record ScrollbarBounds(int x, int y, int width, int height, int contentHeight, int viewportHeight, + int maxScroll) {} + + public record HeadingEntry(String text, int depth, int documentY, int colorArgb, int markerX, int markerY, + int markerWidth, int markerHeight, int hitX, int hitY, int hitWidth, int hitHeight) {} + + public record LabelLayout(List lines, int x, int y, int width, int height, int alpha) {} + + public record RenderState(List entries, int activeIndex, @Nullable Integer hoveredIndex, + @Nullable LabelLayout labelLayout) {} + + public void invalidateLayout() { + lastLayoutSignature = 0; + entries = List.of(); + anchors = List.of(); + hoverState = null; + } + + public void clearHover() { + hoverState = null; + } + + public RenderState update(@Nullable GuidePage page, @Nullable LytDocument document, ScrollbarBounds bounds, + float zoom, int viewportTopDocumentY, int mouseX, int mouseY, long nowMillis, + @Nullable FontRenderer fontRenderer, int labelMaxWidth, int labelGap, int panelLeft, int panelRight) { + rebuildIfNeeded(page, document, bounds, zoom, viewportTopDocumentY); + updateHoverCandidate(mouseX, mouseY, nowMillis); + int activeIndex = resolveActiveIndex(viewportTopDocumentY); + Integer hoveredIndex = hoverState != null ? hoverState.index() : null; + LabelLayout labelLayout = hoveredIndex != null && fontRenderer != null ? buildLabelLayout( + entries.get(hoveredIndex), + fontRenderer, + nowMillis, + labelMaxWidth, + labelGap, + panelLeft, + panelRight) : null; + return new RenderState(entries, activeIndex, hoveredIndex, labelLayout); + } + + public @Nullable Integer findJumpTarget(int mouseX, int mouseY) { + for (var entry : entries) { + if (contains(entry, mouseX, mouseY)) { + clearHover(); + return entry.documentY(); + } + } + return null; + } + + public int resolveActiveIndex(int viewportTopDocumentY) { + int activeIndex = -1; + for (int index = 0; index < entries.size(); index++) { + var entry = entries.get(index); + if (entry.documentY() > viewportTopDocumentY) { + break; + } + activeIndex = index; + } + return activeIndex >= 0 ? activeIndex : entries.isEmpty() ? -1 : 0; + } + + public void updateHoverCandidateForTest(int mouseX, int mouseY, long nowMillis) { + updateHoverCandidate(mouseX, mouseY, nowMillis); + } + + public void setEntriesForTest(List testEntries) { + entries = List.copyOf(testEntries); + } + + public void setAnchorsForTest(List testAnchors) { + anchors = List.copyOf(testAnchors); + } + + public HeadingEntry testEntry(String text, int depth, int documentY) { + return testEntry(text, depth, documentY, 0xFFFFFFFF, 100, 50, markerWidth(depth), MARKER_HEIGHT, 98, 48, 16, 6); + } + + public HeadingEntry testEntry(String text, int depth, int documentY, int colorArgb, int markerX, int markerY, + int markerWidth, int markerHeight, int hitX, int hitY, int hitWidth, int hitHeight) { + return new HeadingEntry( + text, + depth, + documentY, + colorArgb, + markerX, + markerY, + markerWidth, + markerHeight, + hitX, + hitY, + hitWidth, + hitHeight); + } + + public OutlineAnchor testAnchor(String text, int depth, int documentY, int colorArgb) { + return new OutlineAnchor(text, depth, documentY, colorArgb); + } + + public RenderState rebuildFromAnchorsForTest(ScrollbarBounds bounds) { + entries = mapAnchorsToEntries(anchors, bounds); + return new RenderState(entries, -1, null, null); + } + + @Nullable + public LabelLayout visibleHoverLabelForTest(long nowMillis) { + if (hoverState == null || hoverState.index() < 0 || hoverState.index() >= entries.size()) { + return null; + } + if (nowMillis - hoverState.startedAtMillis() < HOVER_DELAY_MILLIS) { + return null; + } + return new LabelLayout( + List.of( + entries.get(hoverState.index()) + .text()), + 0, + 0, + 0, + 0, + 255); + } + + private void rebuildIfNeeded(@Nullable GuidePage page, @Nullable LytDocument document, ScrollbarBounds bounds, + float zoom, int viewportTopDocumentY) { + int layoutSignature = Objects.hash( + page, + page != null ? page.titleHeading() : null, + document, + bounds.x(), + bounds.y(), + bounds.width(), + bounds.height(), + bounds.contentHeight(), + bounds.viewportHeight(), + bounds.maxScroll(), + viewportTopDocumentY, + Float.floatToIntBits(zoom)); + if (layoutSignature == lastLayoutSignature) { + return; + } + anchors = collectAnchors(page, document); + entries = mapAnchorsToEntries(anchors, bounds); + lastLayoutSignature = layoutSignature; + if (hoverState != null) { + if (hoverState.index() < 0 || hoverState.index() >= entries.size()) { + hoverState = null; + } else { + var previous = hoverState; + hoverState = new HoverState(previous.index(), previous.startedAtMillis()); + } + } + } + + private List collectAnchors(@Nullable GuidePage page, @Nullable LytDocument document) { + if (document == null || !document.hasLayout()) { + return List.of(); + } + List collected = new ArrayList<>(); + for (var block : document.getBlocks()) { + if (block instanceof LytHeading heading && heading.getBounds() != null) { + int depth = heading.getDepth(); + if (depth >= 1 && depth <= 2) { + String headingText = heading.getTextContent(); + if (!headingText.isEmpty()) { + collected.add( + new OutlineAnchor( + headingText, + depth, + heading.getBounds() + .y(), + resolveHeadingColor(depth))); + } + } + } + } + collected.sort( + Comparator.comparingInt(OutlineAnchor::documentY) + .thenComparingInt(OutlineAnchor::depth)); + return collected; + } + + private List mapAnchorsToEntries(List anchorEntries, ScrollbarBounds bounds) { + if (anchorEntries.isEmpty() || bounds.height() <= 0) { + return List.of(); + } + int track = Math.max(1, bounds.height() - MARKER_HEIGHT); + int barRight = bounds.x() + bounds.width(); + List mapped = new ArrayList<>(anchorEntries.size()); + for (var anchor : anchorEntries) { + int markerWidth = markerWidth(anchor.depth()); + int clampedDocumentY = Math.max(0, Math.min(anchor.documentY(), bounds.maxScroll())); + int markerY = bounds.y() + + (bounds.maxScroll() > 0 ? (int) ((long) track * clampedDocumentY / bounds.maxScroll()) : 0); + int markerX = barRight - markerWidth - 1; + mapped.add( + new HeadingEntry( + anchor.text(), + anchor.depth(), + anchor.documentY(), + anchor.colorArgb(), + markerX, + markerY, + markerWidth, + MARKER_HEIGHT, + markerX - HIT_PADDING_X, + markerY - HIT_PADDING_Y, + markerWidth + HIT_PADDING_X * 2, + MARKER_HEIGHT + HIT_PADDING_Y * 2)); + } + return mapped; + } + + private void updateHoverCandidate(int mouseX, int mouseY, long nowMillis) { + Integer targetIndex = nearestHoveredIndex(mouseX, mouseY); + if (targetIndex == null) { + hoverState = null; + return; + } + if (hoverState != null && hoverState.index() == targetIndex) { + return; + } + hoverState = new HoverState(targetIndex, nowMillis); + } + + private @Nullable Integer nearestHoveredIndex(int mouseX, int mouseY) { + Integer nearestIndex = null; + int nearestDistance = Integer.MAX_VALUE; + for (int index = 0; index < entries.size(); index++) { + var entry = entries.get(index); + if (!contains(entry, mouseX, mouseY)) { + continue; + } + int centerY = entry.hitY() + entry.hitHeight() / 2; + int distance = Math.abs(mouseY - centerY); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + } + return nearestIndex; + } + + private @Nullable LabelLayout buildLabelLayout(HeadingEntry entry, FontRenderer fontRenderer, long nowMillis, + int labelMaxWidth, int labelGap, int panelLeft, int panelRight) { + if (hoverState == null || nowMillis - hoverState.startedAtMillis() < HOVER_DELAY_MILLIS) { + return null; + } + List wrappedLines = fontRenderer.listFormattedStringToWidth(entry.text(), labelMaxWidth); + if (wrappedLines.isEmpty()) { + wrappedLines = List.of(entry.text()); + } + List lines = new ArrayList<>(wrappedLines.subList(0, Math.min(LABEL_MAX_LINES, wrappedLines.size()))); + if (wrappedLines.size() > LABEL_MAX_LINES && !lines.isEmpty()) { + int lastIndex = lines.size() - 1; + lines.set(lastIndex, truncateLineWithEllipsis(fontRenderer, lines.get(lastIndex), labelMaxWidth)); + } + int width = 0; + for (String line : lines) { + width = Math.max(width, fontRenderer.getStringWidth(line)); + } + width = Math.min(labelMaxWidth, width); + int height = lines.size() * fontRenderer.FONT_HEIGHT; + int x = Math.max(panelLeft + 4, entry.markerX() - labelGap - width - 10); + x = Math.min(x, panelRight - width - 4); + int y = entry.markerY() - height / 2; + return new LabelLayout(List.copyOf(lines), x, y, width, height, 255); + } + + private String truncateLineWithEllipsis(FontRenderer fontRenderer, String line, int maxWidth) { + if (fontRenderer.getStringWidth(line) <= maxWidth) { + return line; + } + int ellipsisWidth = fontRenderer.getStringWidth(ELLIPSIS); + String trimmed = line; + while (!trimmed.isEmpty() && fontRenderer.getStringWidth(trimmed) + ellipsisWidth > maxWidth) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed + ELLIPSIS; + } + + private boolean contains(HeadingEntry entry, int mouseX, int mouseY) { + return mouseX >= entry.hitX() && mouseX < entry.hitX() + entry.hitWidth() + && mouseY >= entry.hitY() + && mouseY < entry.hitY() + entry.hitHeight(); + } + + private int resolveHeadingColor(int depth) { + ResolvedTextStyle baseStyle = DefaultStyles.BASE_STYLE; + ResolvedTextStyle headingStyle = switch (depth) { + case 1 -> DefaultStyles.HEADING1.mergeWith(baseStyle); + case 2 -> DefaultStyles.HEADING2.mergeWith(baseStyle); + default -> baseStyle; + }; + ColorValue colorValue = headingStyle.color() != null ? headingStyle.color() : baseStyle.color(); + return colorValue.resolve(LightDarkMode.LIGHT_MODE); + } + + private static int markerWidth(int depth) { + return switch (depth) { + case 1 -> H1_MARKER_WIDTH; + default -> H2_MARKER_WIDTH; + }; + } + + public record OutlineAnchor(String text, int depth, int documentY, int colorArgb) {} + + record HoverState(int index, long startedAtMillis) {} +} 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 f0eb6ec5..24fc7162 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideSourceWatcher.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideSourceWatcher.java @@ -25,15 +25,14 @@ import com.github.bsideup.jabel.Desugar; import com.google.common.util.concurrent.ThreadFactoryBuilder; -import com.hfstudio.guidenh.config.ModConfig; import com.hfstudio.guidenh.guide.GuidePageChange; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; import com.hfstudio.guidenh.guide.internal.localization.GuideLocalizedPageSourceResolver; import com.hfstudio.guidenh.guide.internal.localization.GuidePageLanguageIndex; import com.hfstudio.guidenh.guide.internal.localization.GuideResourceLanguageIndex; import com.hfstudio.guidenh.guide.internal.util.LangUtil; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; -import cpw.mods.fml.common.FMLLog; import io.methvin.watcher.DirectoryChangeEvent; import io.methvin.watcher.DirectoryChangeListener; import io.methvin.watcher.DirectoryWatcher; @@ -205,10 +204,7 @@ public GuideSourceWatcher(String namespace, String contentRootFolder, String def if (!Files.isDirectory(sourceFolder)) { throw new RuntimeException("Cannot find the specified folder with guidebook sources: " + sourceFolder); } - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info("[GuideNH] [GuideSourceWatcher] Watching guidebook sources in {}", sourceFolder); - } + GuideDebugLog.info("[GuideNH] [GuideSourceWatcher] Watching guidebook sources in {}", sourceFolder); watchExecutor = Executors.newSingleThreadExecutor( new ThreadFactoryBuilder().setDaemon(true) @@ -225,11 +221,10 @@ public GuideSourceWatcher(String namespace, String contentRootFolder, String def .listener(new Listener()) .build(); } catch (IOException e) { - FMLLog.getLogger() - .error( - "[GuideNH] [GuideSourceWatcher] Failed to watch for changes in the guidebook sources at {}", - sourceFolder, - e); + GuideDebugLog.error( + "[GuideNH] [GuideSourceWatcher] Failed to watch for changes in the guidebook sources at {}", + sourceFolder, + e); watcher = null; } sourceWatcher = watcher; @@ -273,33 +268,27 @@ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { @Override public FileVisitResult visitFileFailed(Path file, IOException exc) { - FMLLog.getLogger() - .error("[GuideNH] [GuideSourceWatcher] Failed to list page {}", file, exc); + GuideDebugLog.error("[GuideNH] [GuideSourceWatcher] Failed to list page {}", file, exc); return FileVisitResult.CONTINUE; } @Override public FileVisitResult postVisitDirectory(Path dir, IOException exc) { if (exc != null) { - FMLLog.getLogger() - .error("[GuideNH] [GuideSourceWatcher] Failed to list all pages in {}", dir, exc); + GuideDebugLog.error("[GuideNH] [GuideSourceWatcher] Failed to list all pages in {}", dir, exc); } return FileVisitResult.CONTINUE; } }); } catch (IOException e) { - FMLLog.getLogger() - .error("[GuideNH] [GuideSourceWatcher] Failed to list all pages in {}", sourceFolder, e); + GuideDebugLog.error("[GuideNH] [GuideSourceWatcher] Failed to list all pages in {}", sourceFolder, e); } long walkNs = System.nanoTime() - stageStartedAt; - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideSourceWatcher] Loading {} guidebook pages from {} localized variants", - pageIds.size(), - pageSources.size()); - } + GuideDebugLog.info( + "[GuideNH] [GuideSourceWatcher] Loading {} guidebook pages from {} localized variants", + pageIds.size(), + pageSources.size()); stageStartedAt = System.nanoTime(); Map> localizedSourcesByNamespace = loadLocalizedSourceOverrides( currentLanguage, @@ -324,7 +313,7 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) { Files.readAllBytes(pageSource.path()), pageSource.localizedSourceOverride())); } catch (Exception e) { - FMLLog.getLogger() + GuideDebugLog .error("[GuideNH] [GuideSourceWatcher] Failed to reload guidebook page {}", pageSource.path(), e); } } @@ -332,21 +321,18 @@ public FileVisitResult postVisitDirectory(Path dir, IOException exc) { int sourceLanguageCount = countSourceLanguages(pageSources.keySet()); long totalNs = System.nanoTime() - startedAt; - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideSourceWatcher] Loaded {} pages from {} with namespaceFilter={} sourceVariants={} sourceLanguages={} localizedNamespaces={} walkNs={} localizedOverrideNs={} parseNs={} totalNs={}", - loadedPages.size(), - sourceFolder, - namespaceFilter != null ? namespaceFilter : "", - pageSources.size(), - sourceLanguageCount, - localizedSourcesByNamespace.size(), - walkNs, - localizedOverrideNs, - parseNs, - totalNs); - } + GuideDebugLog.info( + "[GuideNH] [GuideSourceWatcher] Loaded {} pages from {} with namespaceFilter={} sourceVariants={} sourceLanguages={} localizedNamespaces={} walkNs={} localizedOverrideNs={} parseNs={} totalNs={}", + loadedPages.size(), + sourceFolder, + namespaceFilter != null ? namespaceFilter : "", + pageSources.size(), + sourceLanguageCount, + localizedSourcesByNamespace.size(), + walkNs, + localizedOverrideNs, + parseNs, + totalNs); return loadedPages; } @@ -460,7 +446,7 @@ public synchronized void close() { try { sourceWatcher.close(); } catch (IOException e) { - FMLLog.getLogger() + GuideDebugLog .error("[GuideNH] [GuideSourceWatcher] Failed to close fileystem watcher for {}", sourceFolder); } } @@ -486,8 +472,7 @@ public boolean isWatching() { @Override public void onException(Exception e) { - FMLLog.getLogger() - .error("[GuideNH] [GuideSourceWatcher] Failed watching for changes", e); + GuideDebugLog.error("[GuideNH] [GuideSourceWatcher] Failed watching for changes", e); } } @@ -725,11 +710,10 @@ private void clearQueuedPageStates(Set pageIds) { activeSource.localizedSourceOverride()); return new ResolvedPageState(activeSource.requestedLanguage(), page, false); } catch (Exception e) { - FMLLog.getLogger() - .error( - "[GuideNH] [GuideSourceWatcher] Failed to resolve effective guidebook page {}", - activeSource.path(), - e); + GuideDebugLog.error( + "[GuideNH] [GuideSourceWatcher] Failed to resolve effective guidebook page {}", + activeSource.path(), + e); return null; } } @@ -769,8 +753,10 @@ private PageSource resolveActivePageSource(ResourceLocation pageId, String curre Map entries = GuidePageLanguageIndex.readPageKeys(input); return entries.get(GuideLocalizedPageSourceResolver.buildLangKey(contentRootFolder, pageId)); } catch (IOException e) { - FMLLog.getLogger() - .warn("[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", langFilePath, e); + GuideDebugLog.warnAlways( + "[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", + langFilePath, + e); return null; } } @@ -808,11 +794,10 @@ private Set resolveNamespacesForLocalizedSources(@Nullable String namesp child.getFileName() .toString())); } catch (IOException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [GuideSourceWatcher] Failed to scan localized source namespaces in {}", - assetsPath, - e); + GuideDebugLog.warnAlways( + "[GuideNH] [GuideSourceWatcher] Failed to scan localized source namespaces in {}", + assetsPath, + e); } return namespaces; } @@ -839,8 +824,10 @@ private Map loadLocalizedSourceOverridesForNamespace(String sour try (InputStream input = Files.newInputStream(langFilePath)) { return GuidePageLanguageIndex.readPageKeys(input); } catch (IOException e) { - FMLLog.getLogger() - .warn("[GuideNH] [GuideSourceWatcher] Failed to read localized page lang file {}", langFilePath, e); + GuideDebugLog.warnAlways( + "[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/GuideStartupOptions.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideStartupOptions.java index a68cede2..109dda44 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideStartupOptions.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideStartupOptions.java @@ -9,8 +9,7 @@ import com.github.bsideup.jabel.Desugar; import com.hfstudio.guidenh.guide.PageAnchor; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; public class GuideStartupOptions { @@ -39,7 +38,7 @@ public record ShowOnStartup(ResourceLocation guideId, @Nullable PageAnchor ancho return new ShowOnStartup(guideId, parseStartupAnchor(guideId, trimmedValue.substring(anchorSeparator + 1))); } catch (RuntimeException e) { - FMLLog.getLogger() + GuideDebugLog .error("[GuideNH] [GuideStartupOptions] Failed to parse guideme.showOnStartup='{}'", trimmedValue, e); return null; } @@ -69,11 +68,10 @@ public static Set parseValidateAtStartup(@Nullable String valu try { result.add(new ResourceLocation(trimmedToken)); } catch (RuntimeException e) { - FMLLog.getLogger() - .error( - "[GuideNH] [GuideStartupOptions] Failed to parse validateAtStartup guide id '{}'", - trimmedToken, - e); + GuideDebugLog.error( + "[GuideNH] [GuideStartupOptions] Failed to parse validateAtStartup guide id '{}'", + trimmedToken, + e); } } if (end == length) { diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupPump.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupPump.java deleted file mode 100644 index b2be9117..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupPump.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.hfstudio.guidenh.guide.internal; - -import com.hfstudio.guidenh.guide.internal.search.GuideSearch; - -import cpw.mods.fml.common.FMLCommonHandler; -import cpw.mods.fml.common.eventhandler.SubscribeEvent; -import cpw.mods.fml.common.gameevent.TickEvent; - -public class GuideWarmupPump { - - private static final GuideWarmupScheduler SCHEDULER = new GuideWarmupScheduler(); - - private long currentTick; - - public static void init() { - FMLCommonHandler.instance() - .bus() - .register(new GuideWarmupPump()); - } - - public static void clearScheduler() { - SCHEDULER.clear(); - } - - @SubscribeEvent - public void onClientTick(TickEvent.ClientTickEvent event) { - if (event.phase != TickEvent.Phase.END) { - return; - } - - currentTick++; - for (MutableGuide guide : GuideRegistry.getAll()) { - guide.populateWarmupScheduler(SCHEDULER, currentTick); - } - SCHEDULER.processTick(currentTick); - - GuideME.getSearch() - .processWork(GuideSearch.BACKGROUND_TIME_PER_TICK); - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupScheduler.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupScheduler.java deleted file mode 100644 index 0624824a..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupScheduler.java +++ /dev/null @@ -1,90 +0,0 @@ -package com.hfstudio.guidenh.guide.internal; - -import java.util.ArrayDeque; -import java.util.Deque; - -import com.hfstudio.guidenh.config.ModConfig; - -import cpw.mods.fml.common.FMLLog; - -public class GuideWarmupScheduler { - - public static final long NORMAL_RUNTIME_BUDGET_NANOS = 2_000_000L; - public static final long DEVELOPMENT_VALIDATION_BUDGET_NANOS = 1_000_000L; - - private final Deque highPriorityQueue = new ArrayDeque<>(); - private final Deque normalQueue = new ArrayDeque<>(); - private final Deque validationQueue = new ArrayDeque<>(); - - public void enqueue(GuideWarmupWorkItem item) { - switch (item.kind()) { - case HIGH_PRIORITY_PAGE -> highPriorityQueue.offerLast(item); - case NORMAL_PAGE -> normalQueue.offerLast(item); - case DEV_VALIDATION -> validationQueue.offerLast(item); - } - } - - public void clear() { - int highPriorityCount = highPriorityQueue.size(); - int normalCount = normalQueue.size(); - int validationCount = validationQueue.size(); - highPriorityQueue.clear(); - normalQueue.clear(); - validationQueue.clear(); - if (ModConfig.debug.enableDebugMode && (highPriorityCount > 0 || normalCount > 0 || validationCount > 0)) { - FMLLog.getLogger() - .info( - "[GuideNH] [GuideWarmupScheduler] Cleared queued warmup work highPriority={}, normal={}, validation={}", - highPriorityCount, - normalCount, - validationCount); - } - } - - public void processTick(long nowTick) { - processQueue(nowTick, NORMAL_RUNTIME_BUDGET_NANOS, false); - processQueue(nowTick, DEVELOPMENT_VALIDATION_BUDGET_NANOS, true); - } - - private void processQueue(long nowTick, long budgetNanos, boolean validationOnly) { - long deadline = System.nanoTime() + budgetNanos; - while (System.nanoTime() < deadline) { - GuideWarmupWorkItem item = pollEligible(nowTick, validationOnly); - if (item == null) { - return; - } - - MutableGuide guide = GuideRegistry.getById(item.guideId()); - if (guide == null) { - continue; - } - - boolean finished = guide.processWarmupWorkItem(item, nowTick); - if (!finished) { - enqueue(item); - } - } - } - - private GuideWarmupWorkItem pollEligible(long nowTick, boolean validationOnly) { - Deque firstQueue = validationOnly ? validationQueue : highPriorityQueue; - Deque secondQueue = validationOnly ? null : normalQueue; - GuideWarmupWorkItem item = pollEligible(firstQueue, nowTick); - if (item != null || secondQueue == null) { - return item; - } - return pollEligible(secondQueue, nowTick); - } - - private GuideWarmupWorkItem pollEligible(Deque queue, long nowTick) { - while (!queue.isEmpty()) { - GuideWarmupWorkItem item = queue.pollFirst(); - if (item.nextEligibleTick() <= nowTick) { - return item; - } - queue.offerLast(item); - return null; - } - return null; - } -} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupWorkItem.java b/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupWorkItem.java deleted file mode 100644 index 38abe7d9..00000000 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuideWarmupWorkItem.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.hfstudio.guidenh.guide.internal; - -import net.minecraft.util.ResourceLocation; - -public class GuideWarmupWorkItem { - - public enum Kind { - HIGH_PRIORITY_PAGE, - NORMAL_PAGE, - DEV_VALIDATION - } - - private final ResourceLocation guideId; - private final ResourceLocation pageId; - private final Kind kind; - private int stepIndex; - private long nextEligibleTick; - - public GuideWarmupWorkItem(ResourceLocation guideId, ResourceLocation pageId, Kind kind) { - this.guideId = guideId; - this.pageId = pageId; - this.kind = kind; - } - - public ResourceLocation guideId() { - return guideId; - } - - public ResourceLocation pageId() { - return pageId; - } - - public Kind kind() { - return kind; - } - - public int stepIndex() { - return stepIndex; - } - - public void advanceStep() { - stepIndex++; - } - - public long nextEligibleTick() { - return nextEligibleTick; - } - - public void setNextEligibleTick(long nextEligibleTick) { - this.nextEligibleTick = nextEligibleTick; - } -} 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 05525f60..315edf60 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/GuidebookText.java @@ -186,6 +186,7 @@ public enum GuidebookText implements LocalizationEnum { GuideEditorCopy, GuideEditorPaste, GuideEditorSelectAll, + GuideEditorFormatDocument, GuideEditorContextMenuEdit, GuideEditorContextMenuInsert, GuideEditorContextMenuBlocks, @@ -371,10 +372,6 @@ public enum GuidebookText implements LocalizationEnum { RegionWandTooltipPos, RegionWandEntitiesExported, RegionWandSavedSnbt, - Smelting, - Blasting, - ShapelessCrafting, - Crafting, OpenRecipeInNei, FullWidthView, CloseFullWidthView, 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 f91fc45b..c4e84cb6 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/MutableGuide.java @@ -6,11 +6,9 @@ import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -31,13 +29,11 @@ import com.hfstudio.guidenh.guide.GuideItemSettings; import com.hfstudio.guidenh.guide.GuidePage; import com.hfstudio.guidenh.guide.GuidePageChange; -import com.hfstudio.guidenh.guide.PageAnchor; import com.hfstudio.guidenh.guide.compiler.PageCompiler; import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; import com.hfstudio.guidenh.guide.extensions.ExtensionCollection; import com.hfstudio.guidenh.guide.indices.CategoryIndex; import com.hfstudio.guidenh.guide.indices.PageIndex; -import com.hfstudio.guidenh.guide.internal.home.GuideScreenHomeHistory; import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; import com.hfstudio.guidenh.guide.internal.util.LangUtil; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiGuideAggregator; @@ -49,23 +45,14 @@ import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSpecialPageRefreshController; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSyntheticPageFactory; import com.hfstudio.guidenh.guide.mediawiki.MediaWikiSyntheticPageFactory.SyntheticSourceSnapshot; -import com.hfstudio.guidenh.guide.navigation.NavigationNode; import com.hfstudio.guidenh.guide.navigation.NavigationTree; -import com.hfstudio.guidenh.guide.scene.SceneTagCompiler; - -import cpw.mods.fml.common.FMLLog; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; /** * Encapsulates a Guide, which consists of a collection of Markdown pages and associated content, loaded from a * guide-specific subdirectory of resource packs. */ -public class MutableGuide - implements Guide, GuideDevWatcherPump.TickableGuide, MediaWikiListContextProvider, AutoCloseable { - - public static final String ACTIVE_CLIENT_WORLD_REQUIRED_MESSAGE = "active client world"; - private static final int MAX_STRONG_RUNTIME_PAGES = 48; - private static final long DEVELOPMENT_VALIDATION_INTERVAL_TICKS = 10L; - private static final long NORMAL_HEAVY_PAGE_WARMUP_DELAY_TICKS = 8L; +public class MutableGuide implements Guide, MediaWikiListContextProvider, AutoCloseable { private final ResourceLocation id; private final String defaultNamespace; @@ -92,6 +79,7 @@ public class MutableGuide private volatile long fallbackMediaWikiListContextRevision = Long.MIN_VALUE; private volatile long requestedMediaWikiWarmupRevision = Long.MIN_VALUE; private final MediaWikiSpecialPageRefreshController mediaWikiRefreshController = new MediaWikiSpecialPageRefreshController(); + private static final int MAX_STRONG_RUNTIME_PAGES = 64; private final Map compiledPagesWeak = Collections.synchronizedMap(new WeakHashMap<>()); private final LinkedHashMap compiledPagesStrong = new LinkedHashMap<>( 64, @@ -115,8 +103,6 @@ protected boolean removeEldestEntry(Map.Entry eldes @Nullable private GuideSourceWatcher watcher; - private long nextValidationTick; - private long lastSchedulerTick; public MutableGuide(ResourceLocation id, String defaultNamespace, String folder, String defaultLanguage, @Nullable Path developmentSourceFolder, @Nullable String developmentSourceNamespace, @@ -182,8 +168,7 @@ public T getIndex(Class indexClass) { @Nullable public ParsedGuidePage getParsedPage(ResourceLocation id) { if (pages == null) { - FMLLog.getLogger() - .warn("[GuideNH] [MutableGuide] Can't get page {}. Pages not loaded yet.", id); + GuideDebugLog.warnAlways("[GuideNH] [MutableGuide] Can't get page {}. Pages not loaded yet.", id); return null; } @@ -210,22 +195,11 @@ public GuidePage getPage(ResourceLocation id) { GuidePage compiledPage; try { - synchronized (compiledPagesWeak) { - compiledPage = compiledPagesStrong.get(id); - if (compiledPage == null) { - compiledPage = compiledPagesWeak.get(parsedPage); - } - if (compiledPage == null) { - compiledPage = PageCompiler.compile(this, extensions, parsedPage); - } - compiledPagesWeak.put(parsedPage, compiledPage); - compiledPagesStrong.put(id, compiledPage); - } + compiledPage = PageCompiler.compile(this, extensions, parsedPage); clearCompileFailure(id); } catch (Throwable t) { recordCompileFailure(id, buildCompileFailureText(id, t)); - FMLLog.getLogger() - .error("[GuideNH] [MutableGuide] Failed to compile guide page {}", id, t); + GuideDebugLog.error("[GuideNH] [MutableGuide] Failed to compile guide page {}", id, t); compiledPage = buildFailurePage(parsedPage, pageFailures.get(id)); } compiledPage.prepareForDisplay(); @@ -269,8 +243,7 @@ public byte[] loadAsset(ResourceLocation id) { try { return Files.readAllBytes(path); } catch (NoSuchFileException ignored) {} catch (IOException e) { - FMLLog.getLogger() - .error("[GuideNH] [MutableGuide] Failed to open guidebook asset {}", path); + GuideDebugLog.error("[GuideNH] [MutableGuide] Failed to open guidebook asset {}", path); return null; } } @@ -286,8 +259,7 @@ public byte[] loadAsset(ResourceLocation id) { return bytes; } - FMLLog.getLogger() - .error("[GuideNH] [MutableGuide] Failed to open guidebook asset {}", id); + GuideDebugLog.error("[GuideNH] [MutableGuide] Failed to open guidebook asset {}", id); return null; } @@ -365,22 +337,11 @@ public void watchDevelopmentSources() { .addShutdownHook(new Thread(watcher::close)); } - @Override - public boolean hasDevelopmentSources() { - return watcher != null; - } - @Override public synchronized void close() { int developmentPageCount = developmentPages.size(); int syntheticPageCount = syntheticPages.size(); int failureCount = pageFailures.size(); - int weakCompiledCount; - int strongCompiledCount; - synchronized (compiledPagesWeak) { - weakCompiledCount = compiledPagesWeak.size(); - strongCompiledCount = compiledPagesStrong.size(); - } if (watcher != null) { watcher.close(); watcher = null; @@ -395,56 +356,12 @@ public synchronized void close() { mediaWikiSpecialDataIndex = null; fallbackMediaWikiListContextRevision = Long.MIN_VALUE; requestedMediaWikiWarmupRevision = Long.MIN_VALUE; - synchronized (compiledPagesWeak) { - compiledPagesWeak.clear(); - compiledPagesStrong.clear(); - } - warmupPageQueue.clear(); - queuedWarmupPages.clear(); - prioritizedWarmupPages.clear(); - scheduledWarmupPages.clear(); - validationPageQueue.clear(); - queuedValidationPages.clear(); - scheduledValidationPages.clear(); - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [MutableGuide] Closed guide {} and cleared caches developmentPages={}, syntheticPages={}, failures={}, compiledWeak={}, compiledStrong={}", - id, - developmentPageCount, - syntheticPageCount, - failureCount, - weakCompiledCount, - strongCompiledCount); - } - } - - private final Deque warmupPageQueue = new ArrayDeque<>(); - private final HashSet queuedWarmupPages = new HashSet<>(); - private final HashSet prioritizedWarmupPages = new HashSet<>(); - private final HashSet scheduledWarmupPages = new HashSet<>(); - private final Deque validationPageQueue = new ArrayDeque<>(); - private final HashSet queuedValidationPages = new HashSet<>(); - private final HashSet scheduledValidationPages = new HashSet<>(); - - public void resetWarmup() { - rebuildWarmupQueue(); - } - - public void tick() { - if (pages == null || watcher == null) { - return; // Do nothing while pages haven't been loaded yet - } - - var changes = watcher.takeChanges(); - if (!changes.isEmpty()) { - applyChanges(changes); - } - } - - @Override - public void tickDevelopmentSources() { - tick(); + GuideDebugLog.infoAlways( + "[GuideNH] [MutableGuide] Closed guide {} and cleared caches developmentPages={}, syntheticPages={}, failures={}", + id, + developmentPageCount, + syntheticPageCount, + failureCount); } private void applyChanges(List changes) { @@ -471,9 +388,6 @@ private void applyChanges(List changes) { } else { developmentPages.remove(pageId); } - removeCompiledPage(pageId); - prioritizeWarmupPage(pageId); - queueValidationPage(pageId); deduplicatedChanges .set(i, new GuidePageChange(change.language(), pageId, initialPages.get(pageId), newPage)); @@ -498,7 +412,6 @@ private void applyChanges(List changes) { this.navigationTree = buildNavigation(); GuideRegistry.invalidateMergedNavigationTree(); refreshPageFailures(); - requestValidationRefresh(0L); // Reload the current page if it has been changed var guideScreen = GuideScreen.current(); @@ -529,8 +442,7 @@ public void validateAll() { // Iterate and compile all pages to warn about errors on startup for (var entry : developmentPages.entrySet()) { if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info("[GuideNH] [MutableGuide] Compiling {}", entry.getKey()); + GuideDebugLog.infoAlways("[GuideNH] [MutableGuide] Compiling {}", entry.getKey()); } getPage(entry.getKey()); } @@ -555,15 +467,6 @@ public void setPages(Map pages, boolean inval this.pages = Map.copyOf(new HashMap<>(pages)); this.syntheticPages = Map.of(); invalidateMediaWikiDerivedCaches(); - synchronized (compiledPagesWeak) { - compiledPagesWeak.clear(); - compiledPagesStrong.clear(); - } - scheduledWarmupPages.clear(); - scheduledValidationPages.clear(); - validationPageQueue.clear(); - queuedValidationPages.clear(); - nextValidationTick = 0L; if (watcher != null) { watcher.clearChanges(); // Since we'll load them all now, ignore all changes up to now @@ -580,7 +483,6 @@ public void setPages(Map pages, boolean inval GuideRegistry.invalidateMergedNavigationTree(); } refreshPageFailures(); - rebuildWarmupQueue(); // Do not eagerly compile pages here. Some packs register or rewrite recipes // during FMLLoadComplete, after the initial resource reload has already parsed the guide. // Deferring compilation until first display avoids caching stale "missing recipe" error blocks. @@ -603,10 +505,6 @@ public void stageEditorPage(ParsedGuidePage parsedPage) { ResourceLocation pageId = parsedPage.getId(); developmentPages.put(pageId, parsedPage); invalidateMediaWikiDerivedCaches(); - removeCompiledPage(pageId); - prioritizeWarmupPage(pageId); - queueValidationPage(pageId); - requestValidationRefresh(0L); if (parsedPage.hasParseFailure()) { recordParseFailure(parsedPage); } else { @@ -615,182 +513,7 @@ public void stageEditorPage(ParsedGuidePage parsedPage) { } } - private void removeCompiledPage(ResourceLocation pageId) { - synchronized (compiledPagesWeak) { - compiledPagesStrong.remove(pageId); - compiledPagesWeak.keySet() - .removeIf(page -> page != null && pageId.equals(page.getId())); - } - scheduledWarmupPages.remove(pageId); - prioritizedWarmupPages.remove(pageId); - queuedWarmupPages.remove(pageId); - warmupPageQueue.remove(pageId); - scheduledValidationPages.remove(pageId); - queuedValidationPages.remove(pageId); - validationPageQueue.remove(pageId); - } - - private void rebuildWarmupQueue() { - warmupPageQueue.clear(); - queuedWarmupPages.clear(); - prioritizedWarmupPages.clear(); - queueWarmupPage(resolveRememberedWarmupPageId(), true); - queueWarmupBookmarkedPages(); - queueWarmupHistoryPages(); - ResourceLocation firstNavigationPage = resolveWarmupPageId(); - if (firstNavigationPage != null) { - queueWarmupPage(firstNavigationPage, true); - } - if (pages != null) { - for (ParsedGuidePage parsedPage : pages.values()) { - queueWarmupPage(parsedPage.getId()); - } - } - for (ParsedGuidePage parsedPage : developmentPages.values()) { - queueWarmupPage(parsedPage.getId()); - } - } - - private void prioritizeWarmupPage(ResourceLocation pageId) { - queueWarmupPage(pageId, true); - } - - @Nullable - private ResourceLocation pollWarmupCandidate() { - while (!warmupPageQueue.isEmpty()) { - ResourceLocation pageId = warmupPageQueue.poll(); - if (pageId == null) { - return null; - } - queuedWarmupPages.remove(pageId); - if (!compiledPageExists(pageId) && getParsedPage(pageId) != null) { - return pageId; - } - } - return null; - } - - private void queueWarmupPage(@Nullable ResourceLocation pageId) { - queueWarmupPage(pageId, false); - } - - private void queueWarmupPage(@Nullable ResourceLocation pageId, boolean highPriority) { - if (pageId == null || compiledPageExists(pageId)) { - return; - } - if (highPriority) { - prioritizedWarmupPages.add(pageId); - } - if (!queuedWarmupPages.add(pageId)) { - if (highPriority) { - warmupPageQueue.remove(pageId); - warmupPageQueue.offerFirst(pageId); - } - return; - } - if (highPriority) { - warmupPageQueue.offerFirst(pageId); - } else { - warmupPageQueue.offerLast(pageId); - } - } - - @Nullable - private ResourceLocation resolveRememberedWarmupPageId() { - GuideScreenViewState state = GuideScreenMemory.recallLastContentState(); - if (state == null || state.route() == null - || state.route() - .guideId() == null - || !id.equals( - state.route() - .guideId())) { - return null; - } - PageAnchor anchor = state.route() - .anchor(); - return anchor != null ? anchor.pageId() : null; - } - - private void queueWarmupBookmarkedPages() { - for (ResourceLocation pageId : GuideBookmarkState.getSharedInstance() - .getBookmarksView()) { - if (pageExists(pageId)) { - queueWarmupPage(pageId, true); - } - } - } - - private void queueWarmupHistoryPages() { - for (GuideScreenHomeHistory.Entry entry : GuideScreenHomeHistory.shared() - .snapshot()) { - if (id.equals(entry.guideId()) && pageExists(entry.pageId())) { - queueWarmupPage(entry.pageId(), true); - } - } - } - - private boolean compiledPageExists(ResourceLocation pageId) { - ParsedGuidePage parsedPage = getParsedPage(pageId); - if (parsedPage == null) { - return false; - } - synchronized (compiledPagesWeak) { - return compiledPagesStrong.containsKey(pageId) || compiledPagesWeak.containsKey(parsedPage); - } - } - - public void populateWarmupScheduler(GuideWarmupScheduler scheduler, long nowTick) { - if (pages == null) { - return; - } - lastSchedulerTick = nowTick; - GuideScreen guideScreen = GuideScreen.current(); - if (guideScreen != null && guideScreen.isShowingGuide(id)) { - prioritizeWarmupPage(guideScreen.getCurrentPageId()); - } - if (Minecraft.getMinecraft() - .getNetHandler() != null) { - flushWarmupItems(scheduler); - } - if (shouldUseDevelopmentValidation()) { - flushValidationItems(scheduler); - } - } - - public boolean processWarmupWorkItem(GuideWarmupWorkItem item, long nowTick) { - return switch (item.kind()) { - case HIGH_PRIORITY_PAGE, NORMAL_PAGE -> processPageWarmup(item, nowTick); - case DEV_VALIDATION -> processDevelopmentValidation(item, nowTick); - }; - } - - private void flushWarmupItems(GuideWarmupScheduler scheduler) { - ResourceLocation pageId; - while ((pageId = pollWarmupCandidate()) != null) { - if (!scheduledWarmupPages.add(pageId)) { - continue; - } - GuideWarmupWorkItem.Kind kind = prioritizedWarmupPages.remove(pageId) - ? GuideWarmupWorkItem.Kind.HIGH_PRIORITY_PAGE - : GuideWarmupWorkItem.Kind.NORMAL_PAGE; - scheduler.enqueue(new GuideWarmupWorkItem(id, pageId, kind)); - } - } - - private void flushValidationItems(GuideWarmupScheduler scheduler) { - while (!validationPageQueue.isEmpty()) { - ResourceLocation pageId = validationPageQueue.pollFirst(); - if (pageId == null) { - return; - } - queuedValidationPages.remove(pageId); - if (scheduledValidationPages.add(pageId)) { - GuideWarmupWorkItem item = new GuideWarmupWorkItem(id, pageId, GuideWarmupWorkItem.Kind.DEV_VALIDATION); - item.setNextEligibleTick(nextValidationTick); - scheduler.enqueue(item); - } - } - } + // All warmup-related methods removed in Wave 3 cleanup public void rebuildEditorNavigationState() { rebuildIndices(); @@ -817,33 +540,6 @@ public String getDefaultLanguage() { return defaultLanguage; } - @Nullable - private ResourceLocation resolveWarmupPageId() { - if (navigationTree != null) { - ResourceLocation pageId = findFirstNavigationPageId(navigationTree.getRootNodes()); - if (pageId != null) { - return pageId; - } - } - return pages != null && !pages.isEmpty() ? pages.keySet() - .iterator() - .next() : null; - } - - @Nullable - private ResourceLocation findFirstNavigationPageId(List nodes) { - for (var node : nodes) { - if (node.hasPage() && node.pageId() != null) { - return node.pageId(); - } - ResourceLocation childPageId = findFirstNavigationPageId(node.children()); - if (childPageId != null) { - return childPageId; - } - } - return null; - } - private boolean canLoadDevelopmentSource(ResourceLocation id) { if (developmentSourceFolder == null) { return false; @@ -872,148 +568,6 @@ private GuideDevelopmentSourceLayout detectDevelopmentSourceLayout(@Nullable Pat return GuideDevelopmentSourceLayout.detect(sourceFolder, folder); } - private boolean processPageWarmup(GuideWarmupWorkItem item, long nowTick) { - ResourceLocation pageId = item.pageId(); - ParsedGuidePage parsedPage = getParsedPage(pageId); - if (parsedPage == null) { - scheduledWarmupPages.remove(pageId); - return true; - } - - try { - if (item.stepIndex() == 0 && SceneTagCompiler.likelyHasHeavySceneWork(parsedPage)) { - item.advanceStep(); - item.setNextEligibleTick( - nowTick + (item.kind() == GuideWarmupWorkItem.Kind.HIGH_PRIORITY_PAGE ? 1L - : NORMAL_HEAVY_PAGE_WARMUP_DELAY_TICKS)); - return false; - } - if (!warmPage(pageId, parsedPage)) { - item.setNextEligibleTick(nowTick + 1L); - return false; - } - } catch (Throwable t) { - scheduledWarmupPages.remove(pageId); - if (!isDeferrableWarmPageFailure(t)) { - FMLLog.getLogger() - .error("[GuideNH] [MutableGuide] Failed to pre-warm guide page {}", pageId, t); - } - return true; - } - - scheduledWarmupPages.remove(pageId); - return true; - } - - private boolean warmPage(ResourceLocation pageId, ParsedGuidePage parsedPage) { - synchronized (compiledPagesWeak) { - GuidePage compiledPage = compiledPagesStrong.get(pageId); - if (compiledPage == null) { - compiledPage = compiledPagesWeak.get(parsedPage); - } - if (compiledPage != null) { - compiledPagesStrong.put(pageId, compiledPage); - return true; - } - try { - compiledPage = PageCompiler.compile(this, extensions, parsedPage); - } catch (RuntimeException e) { - if (isDeferrableWarmPageFailure(e)) { - FMLLog.getLogger() - .debug( - "[GuideNH] [MutableGuide] Deferring warm compilation for page {} until an active client world is available", - pageId); - return false; - } - throw e; - } - compiledPagesWeak.put(parsedPage, compiledPage); - compiledPagesStrong.put(pageId, compiledPage); - clearCompileFailure(pageId); - return true; - } - } - - private boolean processDevelopmentValidation(GuideWarmupWorkItem item, long nowTick) { - ResourceLocation pageId = item.pageId(); - if (!shouldUseDevelopmentValidation()) { - scheduledValidationPages.remove(pageId); - return true; - } - if (nowTick < nextValidationTick) { - item.setNextEligibleTick(nextValidationTick); - return false; - } - - try { - ParsedGuidePage parsedPage = getParsedPage(pageId); - if (parsedPage == null) { - return true; - } - if (parsedPage.hasParseFailure()) { - recordParseFailure(parsedPage); - return true; - } - PageCompiler.compile(this, extensions, parsedPage); - clearCompileFailure(pageId); - } catch (Throwable t) { - if (!isDeferrableWarmPageFailure(t)) { - recordCompileFailure(pageId, buildCompileFailureText(pageId, t)); - FMLLog.getLogger() - .error("[GuideNH] [MutableGuide] Failed to validate guide page {}", pageId, t); - } - } finally { - scheduledValidationPages.remove(pageId); - } - return true; - } - - private void queueValidationPage(@Nullable ResourceLocation pageId) { - if (pageId == null || !queuedValidationPages.add(pageId) || scheduledValidationPages.contains(pageId)) { - return; - } - validationPageQueue.offerLast(pageId); - } - - private void queueValidationForAllPages() { - for (ParsedGuidePage parsedPage : getAllParsedPages().values()) { - if (!parsedPage.hasParseFailure()) { - queueValidationPage(parsedPage.getId()); - } - } - } - - private void requestValidationRefresh(long nowTick) { - long referenceTick = nowTick > 0L ? nowTick : lastSchedulerTick; - long requestedTick = referenceTick + DEVELOPMENT_VALIDATION_INTERVAL_TICKS; - if (nextValidationTick == 0L) { - nextValidationTick = requestedTick; - } else { - nextValidationTick = Math.min(nextValidationTick, requestedTick); - } - } - - private boolean shouldUseDevelopmentValidation() { - return watcher != null || isGuideEditorRuntimeActive(); - } - - private boolean isGuideEditorRuntimeActive() { - GuideScreen guideScreen = GuideScreen.current(); - return guideScreen != null && guideScreen.isShowingGuide(id) && guideScreen.isEditorActive(); - } - - static boolean isDeferrableWarmPageFailure(Throwable throwable) { - for (Throwable current = throwable; current != null; current = current.getCause()) { - if ((current instanceof IllegalStateException || current instanceof IllegalArgumentException) - && current.getMessage() != null - && current.getMessage() - .contains(ACTIVE_CLIENT_WORLD_REQUIRED_MESSAGE)) { - return true; - } - } - return false; - } - private void refreshPageFailures() { Map allParsedPages = getAllParsedPages(); pageFailures.keySet() @@ -1026,10 +580,6 @@ private void refreshPageFailures() { clearParseFailure(parsedPage.getId()); } } - if (shouldUseDevelopmentValidation()) { - requestValidationRefresh(0L); - queueValidationForAllPages(); - } } private Map getAllParsedPages() { @@ -1071,18 +621,11 @@ private void rebuildSyntheticPages() { syntheticSourceCache, this::parseSyntheticPage); syntheticPages = Map.copyOf(rebuiltPages); - previousSyntheticIds.addAll(syntheticPages.keySet()); - for (ResourceLocation syntheticPageId : previousSyntheticIds) { - removeCompiledPage(syntheticPageId); - } - if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [MutableGuide] Rebuilt {} synthetic pages in {} ms for guide {}", - syntheticPages.size(), - nanosToMillis(System.nanoTime() - startNanos), - id); - } + GuideDebugLog.infoAlways( + "[GuideNH] [MutableGuide] Rebuilt {} synthetic pages in {} ms for guide {}", + syntheticPages.size(), + nanosToMillis(System.nanoTime() - startNanos), + id); } private void invalidateMediaWikiDerivedCaches() { @@ -1131,12 +674,11 @@ private void requestMediaWikiDerivedCacheWarmup(long revision) { } NavigationTree navigationSnapshot = aggregatedGuide.getNavigationTree(); if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [MutableGuide] Scheduling MediaWiki cache warmup for guide {} revision {} with {} pages", - id, - revision, - pagesSnapshot.size()); + GuideDebugLog.infoAlways( + "[GuideNH] [MutableGuide] Scheduling MediaWiki cache warmup for guide {} revision {} with {} pages", + id, + revision, + pagesSnapshot.size()); } mediaWikiRefreshController.requestRefresh(revision, () -> { try { @@ -1161,13 +703,12 @@ private void requestMediaWikiDerivedCacheWarmup(long revision) { fallbackMediaWikiListContextRevision = revision; } if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [MutableGuide] Warmed MediaWiki caches asynchronously in {} ms for guide {} revision {} with {} pages", - nanosToMillis(System.nanoTime() - startNanos), - id, - revision, - pagesSnapshot.size()); + GuideDebugLog.infoAlways( + "[GuideNH] [MutableGuide] Warmed MediaWiki caches asynchronously in {} ms for guide {} revision {} with {} pages", + nanosToMillis(System.nanoTime() - startNanos), + id, + revision, + pagesSnapshot.size()); } } catch (Throwable t) { synchronized (this) { @@ -1175,12 +716,11 @@ private void requestMediaWikiDerivedCacheWarmup(long revision) { requestedMediaWikiWarmupRevision = Long.MIN_VALUE; } } - FMLLog.getLogger() - .warn( - "[GuideNH] [MutableGuide] Failed to warm MediaWiki caches asynchronously for guide {} revision {}", - id, - revision, - t); + GuideDebugLog.warnAlways( + "[GuideNH] [MutableGuide] Failed to warm MediaWiki caches asynchronously for guide {} revision {}", + id, + revision, + t); } }); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/csv/CsvTableParser.java b/src/main/java/com/hfstudio/guidenh/guide/internal/csv/CsvTableParser.java index 27eb2890..8c4afd9c 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/csv/CsvTableParser.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/csv/CsvTableParser.java @@ -50,12 +50,27 @@ private static String normalize(String rawText) { return ""; } - String text = rawText.replace("\r\n", "\n") - .replace('\r', '\n'); - if (!text.isEmpty() && text.charAt(0) == '\uFEFF') { - return text.substring(1); + int start = rawText.charAt(0) == '\uFEFF' ? 1 : 0; + StringBuilder normalized = null; + for (int i = start; i < rawText.length(); i++) { + char current = rawText.charAt(i); + if (current == '\r') { + if (normalized == null) { + normalized = new StringBuilder(rawText.length()); + normalized.append(rawText, start, i); + } + normalized.append('\n'); + if (i + 1 < rawText.length() && rawText.charAt(i + 1) == '\n') { + i++; + } + } else if (normalized != null) { + normalized.append(current); + } + } + if (normalized != null) { + return normalized.toString(); } - return text; + return start == 0 ? rawText : rawText.substring(start); } private static void flushCell(List row, StringBuilder cell) { 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 bf630f1a..750a2aae 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 @@ -2,7 +2,12 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -12,7 +17,10 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.resources.AbstractResourcePack; +import net.minecraft.client.resources.FallbackResourceManager; +import net.minecraft.client.resources.IResourceManager; import net.minecraft.client.resources.IResourcePack; +import net.minecraft.client.resources.SimpleReloadableResourceManager; import net.minecraft.util.ResourceLocation; import com.hfstudio.guidenh.config.ModConfig; @@ -22,30 +30,43 @@ import com.hfstudio.guidenh.guide.internal.MutableGuide; import com.hfstudio.guidenh.guide.internal.resource.GuideResourceAccess; import com.hfstudio.guidenh.guide.internal.util.LangUtil; +import com.hfstudio.guidenh.guide.scene.support.GuideDebugLog; import com.hfstudio.guidenh.mixins.early.fml.AccessorFMLClientHandler; import com.hfstudio.guidenh.mixins.early.minecraft.AccessorAbstractResourcePack; +import com.hfstudio.guidenh.mixins.early.minecraft.AccessorFallbackResourceManager; +import com.hfstudio.guidenh.mixins.early.minecraft.AccessorSimpleReloadableResourceManager; import cpw.mods.fml.client.FMLClientHandler; -import cpw.mods.fml.common.FMLLog; public class DataDrivenGuideLoader { public static final String AUTO_GUIDE_FOLDER = "guidenh"; public static final String LANGUAGE_FOLDER_PREFIX = "_"; + private static final Map, Field> LOOSE_ROOT_FIELDS = new IdentityHashMap<>(); + private static volatile List lastActiveResourcePacks = List.of(); + private static volatile List lastResourceManagerResourcePacks = List.of(); + private static volatile Map> lastResourceManagerDomainsByPack = Map.of(); + private static volatile GuideLanguageDiscoverySnapshot lastGuideLanguageDiscovery = GuideLanguageDiscoverySnapshot + .empty(); private DataDrivenGuideLoader() {} public static Map load() { + return load(getActiveResourcePacks()); + } + + public static Map load(IResourceManager resourceManager) { + return load(getActiveResourcePacks(resourceManager)); + } + + public static Map load(Iterable activeResourcePacks) { long startedAt = System.nanoTime(); long stageStartedAt = startedAt; - var activeResourcePacks = getActiveResourcePacks(); + var resolvedResourcePacks = toList(activeResourcePacks); long resourcePackResolveNs = System.nanoTime() - stageStartedAt; - var discoveredLanguages = new LinkedHashMap>(); stageStartedAt = System.nanoTime(); - for (var resourcePack : activeResourcePacks) { - scanResourcePack(resourcePack, discoveredLanguages); - } + var discoveredLanguages = discoverGuideLanguages(resolvedResourcePacks); long scanNs = System.nanoTime() - stageStartedAt; stageStartedAt = System.nanoTime(); @@ -62,71 +83,96 @@ public static Map load() { int discoveredLanguageCount = countDiscoveredLanguages(discoveredLanguages); long totalNs = System.nanoTime() - startedAt; if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [DataDrivenGuideLoader] Loaded {} guides across {} languages from {} resource packs in {} ns (resourcePackResolveNs={}, scanNs={}, buildNs={})", - guides.size(), - discoveredLanguageCount, - activeResourcePacks.size(), - totalNs, - resourcePackResolveNs, - scanNs, - buildNs); + GuideDebugLog.infoAlways( + "[GuideNH] [DataDrivenGuideLoader] Loaded {} guides across {} languages from {} resource packs in {} ns (resourcePackResolveNs={}, scanNs={}, buildNs={})", + guides.size(), + discoveredLanguageCount, + resolvedResourcePacks.size(), + totalNs, + resourcePackResolveNs, + scanNs, + buildNs); } return guides; } + public static Map> discoverGuideLanguages() { + return discoverGuideLanguages(getActiveResourcePacks()); + } + + public static Map> discoverGuideLanguages( + Iterable activeResourcePacks) { + var resolvedResourcePacks = toList(activeResourcePacks); + GuideLanguageDiscoverySnapshot cached = lastGuideLanguageDiscovery; + if (cached.matches(resolvedResourcePacks)) { + return cached.discoveredLanguages(); + } + + var discoveredLanguages = new LinkedHashMap>(); + for (var resourcePack : resolvedResourcePacks) { + scanResourcePack(resourcePack, discoveredLanguages); + } + + var frozen = freezeDiscoveredLanguages(discoveredLanguages); + lastGuideLanguageDiscovery = new GuideLanguageDiscoverySnapshot(List.copyOf(resolvedResourcePacks), frozen); + return frozen; + } + public static LinkedHashMap> discoverPagePaths(String folder) { + return discoverPagePaths(folder, getActiveResourcePacks()); + } + + public static LinkedHashMap> discoverPagePaths(String folder, + Iterable activeResourcePacks) { long startedAt = System.nanoTime(); - var activeResourcePacks = getActiveResourcePacks(); + var resolvedResourcePacks = toList(activeResourcePacks); var pagePaths = new LinkedHashMap>(); - for (var resourcePack : activeResourcePacks) { + for (var resourcePack : resolvedResourcePacks) { scanPagePathsAllNamespaces(resourcePack, folder, pagePaths); } long totalNs = System.nanoTime() - startedAt; if (ModConfig.debug.enableDebugMode) { - FMLLog.getLogger() - .info( - "[GuideNH] [DataDrivenGuideLoader] Discovered {} page paths across {} namespaces for folder {} from {} resource packs in {} ns", - countDiscoveredPagePaths(pagePaths), - pagePaths.size(), - folder, - activeResourcePacks.size(), - totalNs); + GuideDebugLog.infoAlways( + "[GuideNH] [DataDrivenGuideLoader] Discovered {} page paths across {} namespaces for folder {} from {} resource packs in {} ns", + countDiscoveredPagePaths(pagePaths), + pagePaths.size(), + folder, + resolvedResourcePacks.size(), + totalNs); } return pagePaths; } - private static int countDiscoveredLanguages(Map> discoveredLanguages) { + private static int countDiscoveredPagePaths(LinkedHashMap> pagePaths) { int total = 0; - for (var languages : discoveredLanguages.values()) { - total += languages.size(); + for (var namespacePaths : pagePaths.values()) { + total += namespacePaths.size(); } return total; } - private static int countDiscoveredPagePaths(LinkedHashMap> pagePaths) { + private static int countDiscoveredLanguages(Map> discoveredLanguages) { int total = 0; - for (var namespacePaths : pagePaths.values()) { - total += namespacePaths.size(); + for (var languages : discoveredLanguages.values()) { + total += languages.size(); } return total; } private static void scanPagePathsAllNamespaces(IResourcePack resourcePack, String folder, LinkedHashMap> pagePaths) { - var resourcePackFile = getResourcePackFile(resourcePack); - if (resourcePackFile == null || !resourcePackFile.exists()) { + var resourcePackRoot = getLooseResourcePackRoot(resourcePack); + if (resourcePackRoot == null || !resourcePackRoot.exists()) { return; } - if (!resourcePackFile.isDirectory()) { - scanZipPagePathsAllNamespaces(resourcePackFile, folder, pagePaths); + if (!resourcePackRoot.isDirectory()) { + scanZipPagePathsAllNamespaces(resourcePackRoot, folder, pagePaths); return; } - scanPagePathsAllNamespaces(resourcePackFile, folder, pagePaths); + scanPagePathsAllNamespaces(resourcePack, resourcePackRoot, folder, pagePaths); } public static void scanPagePathsAllNamespaces(File resourcePackRoot, String folder, @@ -138,20 +184,48 @@ public static void scanPagePathsAllNamespaces(File resourcePackRoot, String fold var assetsDir = new File(resourcePackRoot, "assets"); var namespaceDirs = assetsDir.listFiles(File::isDirectory); - if (namespaceDirs == null) { + if (namespaceDirs != null) { + for (var namespaceDir : namespaceDirs) { + scanPagePathsForNamespaceRoot(resourcePackRoot, namespaceDir.getName(), folder, pagePaths); + } + } + scanLoosePagePathsAllNamespaces(resourcePackRoot, folder, pagePaths); + } + + private static void scanPagePathsAllNamespaces(IResourcePack resourcePack, File resourcePackRoot, String folder, + LinkedHashMap> pagePaths) { + for (String namespace : getResourceDomains(resourcePack)) { + scanPagePathsForNamespaceRoot(resourcePackRoot, namespace, folder, pagePaths); + } + } + + private static void scanPagePathsForNamespaceRoot(File resourcePackRoot, String namespace, String folder, + LinkedHashMap> pagePaths) { + if (!isValidNamespace(namespace)) { return; } + var discovered = new LinkedHashSet(); + for (String prefix : pagePathPrefixes(namespace, folder)) { + scanFolderPagePaths(resourcePackRoot, prefix, discovered); + } + if (!discovered.isEmpty()) { + pagePaths.computeIfAbsent(namespace, k -> new LinkedHashSet<>()) + .addAll(discovered); + } + } + + private static void scanLoosePagePathsAllNamespaces(File resourcePackRoot, String folder, + LinkedHashMap> pagePaths) { + var namespaceDirs = resourcePackRoot.listFiles(File::isDirectory); + if (namespaceDirs == null) { + return; + } for (var namespaceDir : namespaceDirs) { - var guideRootDir = new File(namespaceDir, folder); - if (!guideRootDir.isDirectory()) { + if ("assets".equals(namespaceDir.getName())) { continue; } - - var namespace = namespaceDir.getName(); - var prefix = toFolderPrefix(namespace, folder); - var paths = pagePaths.computeIfAbsent(namespace, k -> new LinkedHashSet<>()); - scanFolderPagePaths(resourcePackRoot, prefix, paths); + scanPagePathsForNamespaceRoot(resourcePackRoot, namespaceDir.getName(), folder, pagePaths); } } @@ -204,17 +278,21 @@ private static void scanZipPagePathsAllNamespaces(File resourcePackFile, String } } } catch (IOException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to scan guide pages from resource pack {}", - resourcePackFile.getAbsolutePath(), - e); + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to scan guide pages from resource pack {}", + resourcePackFile.getAbsolutePath(), + e); } } public static Set discoverPagePaths(ResourceLocation guideId, String folder) { + return discoverPagePaths(guideId, folder, getActiveResourcePacks()); + } + + public static Set discoverPagePaths(ResourceLocation guideId, String folder, + Iterable activeResourcePacks) { var result = new LinkedHashSet(); - for (var resourcePack : getActiveResourcePacks()) { + for (var resourcePack : activeResourcePacks) { scanPagePathsForNamespace(resourcePack, guideId.getResourceDomain(), folder, result); } return result; @@ -222,17 +300,19 @@ public static Set discoverPagePaths(ResourceLocation guideId, String fol public static void scanPagePathsForNamespace(IResourcePack resourcePack, String namespace, String folder, Set pagePaths) { - var resourcePackFile = getResourcePackFile(resourcePack); - if (resourcePackFile == null || !resourcePackFile.exists()) { + var resourcePackRoot = getLooseResourcePackRoot(resourcePack); + if (resourcePackRoot == null || !resourcePackRoot.exists()) { return; } - scanPagePathsForNamespace(resourcePackFile, namespace, folder, pagePaths); + scanPagePathsForNamespace(resourcePackRoot, namespace, folder, pagePaths); } public static void scanPagePathsForNamespace(File resourcePackRoot, String namespace, String folder, Set pagePaths) { if (resourcePackRoot.isDirectory()) { - scanFolderPagePaths(resourcePackRoot, toFolderPrefix(namespace, folder), pagePaths); + for (String prefix : pagePathPrefixes(namespace, folder)) { + scanFolderPagePaths(resourcePackRoot, prefix, pagePaths); + } } else { scanZipPagePaths(resourcePackRoot, toFolderPrefix(namespace, folder), pagePaths); } @@ -240,7 +320,33 @@ public static void scanPagePathsForNamespace(File resourcePackRoot, String names public static List getActiveResourcePacks() { var resourcePacks = new LinkedHashSet(GuideDevelopmentResourcePacks.getConfiguredPacks()); + resourcePacks.addAll(lastResourceManagerResourcePacks); + addConfiguredResourcePacks(resourcePacks); + var resolved = new ArrayList<>(resourcePacks); + lastActiveResourcePacks = List.copyOf(resolved); + return resolved; + } + + public static List getActiveResourcePacks(IResourceManager resourceManager) { + var resourcePacks = new LinkedHashSet(GuideDevelopmentResourcePacks.getConfiguredPacks()); + var resourceManagerResourcePacks = new LinkedHashSet(); + var domainsByPack = new IdentityHashMap>(); + addResourceManagerResourcePacks(resourceManager, resourceManagerResourcePacks, domainsByPack); + lastResourceManagerResourcePacks = List.copyOf(resourceManagerResourcePacks); + lastResourceManagerDomainsByPack = freezeDomainsByPack(domainsByPack); + resourcePacks.addAll(resourceManagerResourcePacks); + addConfiguredResourcePacks(resourcePacks); + var resolved = new ArrayList<>(resourcePacks); + lastActiveResourcePacks = List.copyOf(resolved); + return resolved; + } + + public static List getLastActiveResourcePacks() { + List snapshot = lastActiveResourcePacks; + return snapshot.isEmpty() ? getActiveResourcePacks() : snapshot; + } + private static void addConfiguredResourcePacks(LinkedHashSet resourcePacks) { try { var accessor = (AccessorFMLClientHandler) FMLClientHandler.instance(); var basePacks = accessor.guidenh$getResourcePackList(); @@ -248,10 +354,9 @@ public static List getActiveResourcePacks() { resourcePacks.addAll(basePacks); } } catch (RuntimeException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to inspect the currently loaded base resource packs", - e); + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to inspect the currently loaded base resource packs", + e); } var repository = Minecraft.getMinecraft() @@ -267,34 +372,86 @@ public static List getActiveResourcePacks() { if (serverPack != null) { resourcePacks.add(serverPack); } + } + + private static void addResourceManagerResourcePacks(IResourceManager resourceManager, + LinkedHashSet resourcePacks, + IdentityHashMap> domainsByPack) { + if (!(resourceManager instanceof SimpleReloadableResourceManager)) { + return; + } + + try { + var accessor = (AccessorSimpleReloadableResourceManager) resourceManager; + Map domainManagers = accessor.guidenh$getDomainResourceManagers(); + if (domainManagers == null || domainManagers.isEmpty()) { + return; + } - return new ArrayList<>(resourcePacks); + for (String domain : resourceManager.getResourceDomains()) { + FallbackResourceManager fallbackResourceManager = domainManagers.get(domain); + if (fallbackResourceManager == null) { + continue; + } + List packs = ((AccessorFallbackResourceManager) fallbackResourceManager) + .guidenh$getResourcePacks(); + if (packs != null) { + for (IResourcePack pack : packs) { + resourcePacks.add(pack); + domainsByPack.computeIfAbsent(pack, ignored -> new LinkedHashSet<>()) + .add(domain); + } + } + } + } catch (RuntimeException e) { + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to inspect the currently loaded resource manager packs", + e); + } + } + + private static Map> freezeDomainsByPack( + IdentityHashMap> domainsByPack) { + if (domainsByPack.isEmpty()) { + return Map.of(); + } + + var result = new IdentityHashMap>(); + for (var entry : domainsByPack.entrySet()) { + result.put(entry.getKey(), Set.copyOf(entry.getValue())); + } + return Collections.unmodifiableMap(result); + } + + private static Set getResourceDomains(IResourcePack resourcePack) { + Set cachedDomains = lastResourceManagerDomainsByPack.get(resourcePack); + return cachedDomains != null ? cachedDomains : resourcePack.getResourceDomains(); } public static void scanResourcePack(IResourcePack resourcePack, Map> discoveredLanguages) { - var resourcePackFile = getResourcePackFile(resourcePack); - if (resourcePackFile == null || !resourcePackFile.exists()) { + var resourcePackRoot = getLooseResourcePackRoot(resourcePack); + if (resourcePackRoot == null || !resourcePackRoot.exists()) { return; } - if (resourcePackFile.isDirectory()) { - scanResourcePackFolder(resourcePackFile, discoveredLanguages); + if (resourcePackRoot.isDirectory()) { + scanResourcePackFolder(resourcePack, resourcePackRoot, discoveredLanguages); } else { - scanResourcePackZip(resourcePackFile, discoveredLanguages); + scanResourcePackZip(resourcePackRoot, discoveredLanguages); } } public static void scanPagePaths(IResourcePack resourcePack, String prefix, Set pagePaths) { - var resourcePackFile = getResourcePackFile(resourcePack); - if (resourcePackFile == null || !resourcePackFile.exists()) { + var resourcePackRoot = getLooseResourcePackRoot(resourcePack); + if (resourcePackRoot == null || !resourcePackRoot.exists()) { return; } - if (resourcePackFile.isDirectory()) { - scanFolderPagePaths(resourcePackFile, prefix, pagePaths); + if (resourcePackRoot.isDirectory()) { + scanFolderPagePaths(resourcePackRoot, prefix, pagePaths); } else { - scanZipPagePaths(resourcePackFile, prefix, pagePaths); + scanZipPagePaths(resourcePackRoot, prefix, pagePaths); } } @@ -311,30 +468,124 @@ public static File getResourcePackFile(IResourcePack resourcePack) { try { return ((AccessorAbstractResourcePack) resourcePack).guidenh$getResourcePackFile(); } catch (RuntimeException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to resolve the backing file for resource pack {}", - resourcePack.getPackName(), - e); + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to resolve the backing file for resource pack {}", + resourcePack.getPackName(), + e); return null; } } + public static File getLooseResourcePackRoot(IResourcePack resourcePack) { + File resourcePackFile = getResourcePackFile(resourcePack); + if (resourcePackFile != null) { + return resourcePackFile; + } + + Field field = findLooseRootField(resourcePack.getClass()); + if (field == null) { + return null; + } + + try { + Object value = field.get(resourcePack); + if (value instanceof Path path) { + return path.toFile(); + } + if (value instanceof File file) { + return file; + } + } catch (IllegalAccessException e) { + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to resolve the directory root for resource pack {}", + resourcePack.getPackName(), + e); + } + return null; + } + + private static Field findLooseRootField(Class resourcePackClass) { + synchronized (LOOSE_ROOT_FIELDS) { + if (LOOSE_ROOT_FIELDS.containsKey(resourcePackClass)) { + return LOOSE_ROOT_FIELDS.get(resourcePackClass); + } + + Field field = discoverLooseRootField(resourcePackClass); + LOOSE_ROOT_FIELDS.put(resourcePackClass, field); + return field; + } + } + + private static Field discoverLooseRootField(Class resourcePackClass) { + Class current = resourcePackClass; + while (current != null && current != Object.class) { + for (Field field : current.getDeclaredFields()) { + Class type = field.getType(); + if (type == Path.class || type == File.class) { + field.setAccessible(true); + return field; + } + } + current = current.getSuperclass(); + } + return null; + } + public static byte[] readBytes(IResourcePack resourcePack, ResourceLocation resourceLocation) { if (!resourcePack.resourceExists(resourceLocation)) { - return null; + return readLooseBytes(resourcePack, resourceLocation); } try (var input = resourcePack.getInputStream(resourceLocation)) { return GuideResourceAccess.readFully(input); } catch (IOException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to read resource {} from resource pack {}", - resourceLocation, + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to read resource {} from resource pack {}", + resourceLocation, + resourcePack.getPackName(), + e); + return null; + } + } + + public static byte[] readLooseBytes(IResourcePack resourcePack, ResourceLocation resourceLocation) { + File looseRoot = getLooseResourcePackRoot(resourcePack); + if (looseRoot == null || !looseRoot.isDirectory()) { + return null; + } + + Path root = looseRoot.toPath() + .toAbsolutePath() + .normalize(); + for (String candidate : looseResourceCandidates(resourceLocation)) { + Path path = root.resolve(candidate.replace('/', File.separatorChar)) + .normalize(); + if (!path.startsWith(root) || !Files.isRegularFile(path)) { + continue; + } + try { + return Files.readAllBytes(path); + } catch (IOException e) { + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to read loose resource {} from resource pack {}", + path, resourcePack.getPackName(), e); - return null; + return null; + } + } + return null; + } + + private static List looseResourceCandidates(ResourceLocation resourceLocation) { + String namespace = resourceLocation.getResourceDomain(); + String path = resourceLocation.getResourcePath(); + var candidates = new ArrayList(); + candidates.add("assets/" + namespace + "/" + path); + candidates.add(namespace + "/" + path); + if (path.startsWith(AUTO_GUIDE_FOLDER + "/")) { + candidates.add(path); } + return candidates; } public static IResourcePack findResourcePack(ResourceLocation resourceLocation) { @@ -352,35 +603,76 @@ public static void scanResourcePackFolder(File resourcePackRoot, Map> discoveredLanguages) { var assetsDir = new File(resourcePackRoot, "assets"); var namespaceDirs = assetsDir.listFiles(File::isDirectory); - if (namespaceDirs == null) { - return; + if (namespaceDirs != null) { + for (var namespaceDir : namespaceDirs) { + scanResourcePackFolderNamespace(resourcePackRoot, namespaceDir.getName(), discoveredLanguages); + } } - for (var namespaceDir : namespaceDirs) { - var guideRootDir = new File(namespaceDir, AUTO_GUIDE_FOLDER); - if (!guideRootDir.isDirectory()) { + var looseNamespaceDirs = resourcePackRoot.listFiles(File::isDirectory); + if (looseNamespaceDirs == null) { + return; + } + for (var namespaceDir : looseNamespaceDirs) { + if ("assets".equals(namespaceDir.getName())) { continue; } + scanResourcePackFolderNamespace(resourcePackRoot, namespaceDir.getName(), discoveredLanguages); + } + } - var languageDirs = guideRootDir.listFiles(File::isDirectory); - if (languageDirs == null) { - continue; - } + private static void scanResourcePackFolder(IResourcePack resourcePack, File resourcePackRoot, + Map> discoveredLanguages) { + for (String namespace : getResourceDomains(resourcePack)) { + scanResourcePackFolderNamespace(resourcePackRoot, namespace, discoveredLanguages); + } + } - for (var languageDir : languageDirs) { - var languageFolder = languageDir.getName(); - if (!isLanguageFolder(languageFolder)) { - continue; - } + private static void scanResourcePackFolderNamespace(File resourcePackRoot, String namespace, + Map> discoveredLanguages) { + if (!isValidNamespace(namespace)) { + return; + } + scanResourcePackFolderNamespaceRoot( + namespace, + new File(resourcePackRoot, toFolderPrefix(namespace, AUTO_GUIDE_FOLDER).replace('/', File.separatorChar)), + discoveredLanguages); + scanResourcePackFolderNamespaceRoot( + namespace, + new File( + resourcePackRoot, + toLooseFolderPrefix(namespace, AUTO_GUIDE_FOLDER).replace('/', File.separatorChar)), + discoveredLanguages); + if (AUTO_GUIDE_FOLDER.equals(namespace)) { + scanResourcePackFolderNamespaceRoot( + namespace, + new File(resourcePackRoot, AUTO_GUIDE_FOLDER.replace('/', File.separatorChar)), + discoveredLanguages); + } + } - if (!containsMarkdownFiles(languageDir)) { - continue; - } + private static void scanResourcePackFolderNamespaceRoot(String namespace, File guideRootDir, + Map> discoveredLanguages) { + if (!guideRootDir.isDirectory()) { + return; + } + var languageDirs = guideRootDir.listFiles(File::isDirectory); + if (languageDirs == null) { + return; + } + for (var languageDir : languageDirs) { + var languageFolder = languageDir.getName(); + if (!isLanguageFolder(languageFolder)) { + continue; + } - var guideId = new ResourceLocation(namespaceDir.getName(), AUTO_GUIDE_FOLDER); - discoveredLanguages.computeIfAbsent(guideId, ignored -> new LinkedHashSet<>()) - .add(toLanguageCode(languageFolder)); + if (!containsMarkdownFiles(languageDir)) { + continue; } + + var guideId = new ResourceLocation(namespace, AUTO_GUIDE_FOLDER); + discoveredLanguages.computeIfAbsent(guideId, ignored -> new LinkedHashSet<>()) + .add(toLanguageCode(languageFolder)); } } @@ -430,11 +722,10 @@ public static void scanResourcePackZip(File resourcePackFile, .add(toLanguageCode(languageFolder)); } } catch (IOException e) { - FMLLog.getLogger() - .warn( - "[GuideNH] [DataDrivenGuideLoader] Failed to scan guide languages from resource pack {}", - resourcePackFile.getAbsolutePath(), - e); + GuideDebugLog.warnAlways( + "[GuideNH] [DataDrivenGuideLoader] Failed to scan guide languages from resource pack {}", + resourcePackFile.getAbsolutePath(), + e); } } @@ -484,11 +775,10 @@ public static void scanZipPagePaths(File resourcePackFile, String prefix, Set pagePathPrefixes(String namespace, String folder) { + var prefixes = new ArrayList(3); + prefixes.add(toFolderPrefix(namespace, folder)); + prefixes.add(toLooseFolderPrefix(namespace, folder)); + if (folder.equals(namespace)) { + prefixes.add(folder + "/"); + } + return prefixes; + } + + private static boolean isValidNamespace(String namespace) { + return namespace != null && !namespace.isEmpty() && namespace.indexOf('/') < 0 && namespace.indexOf('\\') < 0; + } + + private static List toList(Iterable resourcePacks) { + var result = new ArrayList(); + for (IResourcePack resourcePack : resourcePacks) { + result.add(resourcePack); + } + return result; + } + + public static void clearCaches() { + lastGuideLanguageDiscovery = GuideLanguageDiscoverySnapshot.empty(); + } + + private static Map> freezeDiscoveredLanguages( + Map> discoveredLanguages) { + if (discoveredLanguages.isEmpty()) { + return Map.of(); + } + + var frozen = new LinkedHashMap>(discoveredLanguages.size()); + for (var entry : discoveredLanguages.entrySet()) { + frozen.put(entry.getKey(), Set.copyOf(entry.getValue())); + } + return Collections.unmodifiableMap(frozen); + } + + private record GuideLanguageDiscoverySnapshot(List resourcePacks, + Map> discoveredLanguages) { + + private static GuideLanguageDiscoverySnapshot empty() { + return new GuideLanguageDiscoverySnapshot(List.of(), Map.of()); + } + + private boolean matches(List otherResourcePacks) { + return !resourcePacks.isEmpty() && resourcePacks.equals(otherResourcePacks); + } + } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/SceneEditorScreen.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/SceneEditorScreen.java index ca0481db..d3ed3ef3 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/SceneEditorScreen.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/SceneEditorScreen.java @@ -290,6 +290,8 @@ public class SceneEditorScreen extends GuiScreen { private boolean previewFrameOverlayVisible; private boolean previewDirty; private boolean preservePreviewCameraOnNextRebuild; + private int activeMouseDragButton = -1; + private long activeMouseDragStartedAt; @Nullable private Integer previewVisibleLayerOverride; @Nullable @@ -626,6 +628,17 @@ private void pollActivePreviewSceneDrag() { } } + private void pollContinuousMouseDrag(int mouseX, int mouseY) { + if (activeMouseDragButton < 0 || activeMouseDragStartedAt <= 0L) { + return; + } + if (activeMouseDragButton <= 2 && !Mouse.isButtonDown(activeMouseDragButton)) { + return; + } + long heldTime = Minecraft.getSystemTime() - activeMouseDragStartedAt; + mouseClickMove(mouseX, mouseY, activeMouseDragButton, heldTime); + } + @Override protected void actionPerformed(GuiButton button) { if (button.id == CLOSE_BUTTON_ID) { @@ -742,6 +755,7 @@ public void drawScreen(int mouseX, int mouseY, float partialTicks) { addElementButton.visible = !rightPanelCollapsed; } syncToolbarToggleState(); + pollContinuousMouseDrag(mouseX, mouseY); pollActivePreviewSceneDrag(); drawTiledBackground(); @@ -847,6 +861,8 @@ public void handleMouseInput() { @Override protected void mouseClicked(int mouseX, int mouseY, int button) { + activeMouseDragButton = button; + activeMouseDragStartedAt = Minecraft.getSystemTime(); if (closeConfirmDialogOpen) { handleCloseConfirmDialogClick(mouseX, mouseY, button); return; @@ -1078,6 +1094,10 @@ protected void mouseClickMove(int mouseX, int mouseY, int clickedMouseButton, lo @Override protected void mouseMovedOrUp(int mouseX, int mouseY, int state) { + if (state != -1) { + activeMouseDragButton = -1; + activeMouseDragStartedAt = 0L; + } if (markdownTextArea != null && state != -1) { markdownTextArea.mouseReleased(state); } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AttrType.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AttrType.java new file mode 100644 index 00000000..0d6274eb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AttrType.java @@ -0,0 +1,24 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +public enum AttrType { + STRING, + INT, + FLOAT, + BOOLEAN, + COLOR, + ENUM, + ITEM_ID, + BLOCK_ID, + ORE_DICT, + ENTITY_ID, + KEY_BIND, + PAGE_PATH, + FILE_PATH, + QUEST_UUID, + COMMAND, + EXPRESSION, + DOMAIN, + FORMAT_PATTERN, + VECTOR3, + SNBT +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AttributeSpec.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AttributeSpec.java new file mode 100644 index 00000000..a927af51 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AttributeSpec.java @@ -0,0 +1,30 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +public class AttributeSpec { + + private final String name; + private final AttrType type; + private final Class> enumClass; + + public AttributeSpec(String name, AttrType type) { + this(name, type, null); + } + + public AttributeSpec(String name, AttrType type, Class> enumClass) { + this.name = name; + this.type = type; + this.enumClass = enumClass; + } + + public String getName() { + return name; + } + + public AttrType getType() { + return type; + } + + public Class> getEnumClass() { + return enumClass; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteCommit.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteCommit.java new file mode 100644 index 00000000..9fc4787e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteCommit.java @@ -0,0 +1,26 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +public class AutocompleteCommit { + + private final String text; + private final int selectionStart; + private final int selectionEnd; + + public AutocompleteCommit(String text, int selectionStart, int selectionEnd) { + this.text = text; + this.selectionStart = selectionStart; + this.selectionEnd = selectionEnd; + } + + public String getText() { + return text; + } + + public int getSelectionStart() { + return selectionStart; + } + + public int getSelectionEnd() { + return selectionEnd; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteCommitService.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteCommitService.java new file mode 100644 index 00000000..45b54a00 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteCommitService.java @@ -0,0 +1,223 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +import java.util.List; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.AutocompleteCandidate; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.FrontmatterContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxAttrNameContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxValueContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.TagStartContext; + +public class AutocompleteCommitService { + + private AutocompleteCommitService() {} + + public static AutocompleteCommit commit(String text, AutocompleteContext context, AutocompleteCandidate candidate) { + String source = text != null ? text : ""; + int replaceStart = clamp(context.replaceStart(), 0, source.length()); + int replaceEnd = clamp(context.replaceEnd(), replaceStart, source.length()); + Replacement replacement = createReplacement(source, context, candidate.replacementText()); + String newText = source.substring(0, replaceStart) + replacement.text + source.substring(replaceEnd); + int cursor = replaceStart + replacement.cursorOffset; + int selectionEnd = replaceStart + replacement.selectionEndOffset; + return new AutocompleteCommit(newText, cursor, selectionEnd); + } + + private static Replacement createReplacement(String source, AutocompleteContext context, String rawText) { + String replacement = rawText != null ? rawText : ""; + if (context instanceof TagStartContext) { + return createTagReplacement(source, (TagStartContext) context, replacement); + } + if (context instanceof MdxAttrNameContext) { + return createAttributeNameReplacement(source, (MdxAttrNameContext) context, replacement); + } + if (context instanceof MdxValueContext) { + return createAttributeValueReplacement(source, (MdxValueContext) context, replacement); + } + if (context instanceof FrontmatterContext) { + return createFrontmatterReplacement(source, (FrontmatterContext) context, replacement); + } + return Replacement.cursorAtEnd(replacement); + } + + private static Replacement createTagReplacement(String source, TagStartContext context, String tagName) { + int replaceEnd = clamp(context.replaceEnd(), 0, source.length()); + int pos = skipSpaces(source, replaceEnd); + if (pos < source.length()) { + char next = source.charAt(pos); + if (next == '>' || next == '/') { + return Replacement.cursorAtEnd(tagName); + } + } + String text = tagName + " />"; + return new Replacement(text, text.length() - 2, text.length() - 2); + } + + private static Replacement createAttributeNameReplacement(String source, MdxAttrNameContext context, + String attributeName) { + AttributeSpec spec = findSpec(context.getTagName(), attributeName); + if (spec == null) { + return Replacement.cursorAtEnd(attributeName); + } + + AttrType type = spec.getType(); + String text; + int cursorOffset; + int selectionEndOffset; + if (type == AttrType.BOOLEAN) { + text = attributeName + "={true}"; + cursorOffset = attributeName.length() + 2; + selectionEndOffset = cursorOffset + 4; + } else if (shouldUseBraceValue(type)) { + text = attributeName + "={}"; + cursorOffset = text.length() - 1; + selectionEndOffset = cursorOffset; + } else { + text = attributeName + "=\"\""; + cursorOffset = text.length() - 1; + selectionEndOffset = cursorOffset; + } + return new Replacement(text, cursorOffset, selectionEndOffset); + } + + private static Replacement createAttributeValueReplacement(String source, MdxValueContext context, String value) { + int replaceStart = clamp(context.replaceStart(), 0, source.length()); + int replaceEnd = clamp(context.replaceEnd(), replaceStart, source.length()); + ValueEnvelope envelope = findValueEnvelope(source, replaceStart, replaceEnd); + String replacement = value; + int cursorOffset = replacement.length(); + int selectionEndOffset = cursorOffset; + + if (!envelope.hasDelimiter && shouldQuoteAttributeValue(context)) { + replacement = "\"" + replacement + "\""; + cursorOffset = replacement.length(); + selectionEndOffset = cursorOffset; + } else if (context.getMissingValueTerminator() != '\0' + && !endsWith(replacement, context.getMissingValueTerminator())) { + replacement += context.getMissingValueTerminator(); + cursorOffset = replacement.length() - 1; + selectionEndOffset = cursorOffset; + } + return new Replacement(replacement, cursorOffset, selectionEndOffset); + } + + private static AttributeSpec findSpec(String tagName, String attributeName) { + List specs = TagAttributeRegistry.get(tagName); + for (AttributeSpec spec : specs) { + if (spec.getName() + .equals(attributeName)) { + return spec; + } + } + return null; + } + + private static boolean shouldUseBraceValue(AttrType type) { + switch (type) { + case INT: + case FLOAT: + case VECTOR3: + case SNBT: + case EXPRESSION: + return true; + default: + return false; + } + } + + private static boolean shouldQuoteAttributeValue(MdxValueContext context) { + AttributeSpec spec = findSpec(context.getTagName(), context.getAttrName()); + if (spec == null) { + return false; + } + switch (spec.getType()) { + case INT: + case FLOAT: + case BOOLEAN: + case VECTOR3: + case SNBT: + case EXPRESSION: + return false; + default: + return true; + } + } + + private static Replacement createFrontmatterReplacement(String source, FrontmatterContext context, String rawText) { + String replacement = rawText != null ? rawText : ""; + if (!context.isValue()) { + replacement += ": "; + } else if (!replacement.isEmpty() && replacement.charAt(0) == '\n') { + int start = context.replaceStart(); + if (start > 0 && source.charAt(start - 1) == '\n') { + replacement = replacement.substring(1); + } + } + return new Replacement(replacement, replacement.length(), replacement.length()); + } + + private static ValueEnvelope findValueEnvelope(String source, int valueStart, int valueEnd) { + int before = valueStart - 1; + if (before >= 0) { + char open = source.charAt(before); + if (open == '"' || open == '\'' || open == '{') { + return new ValueEnvelope(true); + } + } + if (valueEnd < source.length()) { + char close = source.charAt(valueEnd); + if (close == '"' || close == '\'' || close == '}') { + return new ValueEnvelope(true); + } + } + return new ValueEnvelope(false); + } + + private static int skipSpaces(String source, int start) { + int pos = start; + while (pos < source.length() && Character.isWhitespace(source.charAt(pos))) { + pos++; + } + return pos; + } + + private static boolean endsWith(String value, char suffix) { + return !value.isEmpty() && value.charAt(value.length() - 1) == suffix; + } + + private static int clamp(int value, int min, int max) { + if (value < min) { + return min; + } + if (value > max) { + return max; + } + return value; + } + + private static class Replacement { + + private final String text; + private final int cursorOffset; + private final int selectionEndOffset; + + private Replacement(String text, int cursorOffset, int selectionEndOffset) { + this.text = text; + this.cursorOffset = cursorOffset; + this.selectionEndOffset = selectionEndOffset; + } + + private static Replacement cursorAtEnd(String text) { + return new Replacement(text, text.length(), text.length()); + } + } + + private static class ValueEnvelope { + + private final boolean hasDelimiter; + + private ValueEnvelope(boolean hasDelimiter) { + this.hasDelimiter = hasDelimiter; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteContext.java new file mode 100644 index 00000000..693cfa9e --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteContext.java @@ -0,0 +1,10 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +public interface AutocompleteContext { + + int replaceStart(); + + int replaceEnd(); + + String getPartialText(); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteKeyPolicy.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteKeyPolicy.java new file mode 100644 index 00000000..9a02a54b --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/AutocompleteKeyPolicy.java @@ -0,0 +1,33 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +import org.lwjgl.input.Keyboard; + +public class AutocompleteKeyPolicy { + + private AutocompleteKeyPolicy() {} + + public static boolean shouldCloseForKey(char typedChar, int keyCode) { + if (isModifierOnlyKey(keyCode)) { + return false; + } + return typedChar >= 32 || keyCode == Keyboard.KEY_BACK + || keyCode == Keyboard.KEY_DELETE + || keyCode == Keyboard.KEY_LEFT + || keyCode == Keyboard.KEY_RIGHT + || keyCode == Keyboard.KEY_HOME + || keyCode == Keyboard.KEY_END + || keyCode == Keyboard.KEY_PRIOR + || keyCode == Keyboard.KEY_NEXT + || keyCode == Keyboard.KEY_SPACE; + } + + private static boolean isModifierOnlyKey(int keyCode) { + return keyCode == Keyboard.KEY_LCONTROL || keyCode == Keyboard.KEY_RCONTROL + || keyCode == Keyboard.KEY_LSHIFT + || keyCode == Keyboard.KEY_RSHIFT + || keyCode == Keyboard.KEY_LMENU + || keyCode == Keyboard.KEY_RMENU + || keyCode == Keyboard.KEY_LMETA + || keyCode == Keyboard.KEY_RMETA; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SelectionStrategy.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SelectionStrategy.java new file mode 100644 index 00000000..d0ab7c03 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SelectionStrategy.java @@ -0,0 +1,8 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +public interface SelectionStrategy { + + int getSelectionStart(TextSyntaxContext ctx, String text, int cursorIndex); + + int getSelectionEnd(TextSyntaxContext ctx, String text, int cursorIndex); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxContextResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxContextResolver.java new file mode 100644 index 00000000..f628edf9 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxContextResolver.java @@ -0,0 +1,9 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +import org.jetbrains.annotations.Nullable; + +public interface SyntaxContextResolver { + + @Nullable + TextSyntaxContext resolve(String text, int cursorIndex); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxElementType.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxElementType.java new file mode 100644 index 00000000..63183f37 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxElementType.java @@ -0,0 +1,12 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +public enum SyntaxElementType { + WORD, + TAG_NAME, + TAG_START, + ATTRIBUTE_NAME, + ATTRIBUTE_VALUE, + FENCE_LANGUAGE, + FRONTMATTER_KEY, + OTHER +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxUtils.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxUtils.java new file mode 100644 index 00000000..7f3ec26d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/SyntaxUtils.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +/** Shared text-scanning utilities for syntax resolvers. */ +public class SyntaxUtils { + + private SyntaxUtils() {} + + /** Returns true if {@code c} is a word character (letter, digit, or common separators). */ + public static boolean isWordChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '.' || c == ':'; + } + + /** Resolves cursor to word boundaries in plain text. */ + public static TextSyntaxContext resolveWord(String text, int cursorIndex) { + int start = cursorIndex; + while (start > 0 && isWordChar(text.charAt(start - 1))) start--; + int end = cursorIndex; + while (end < text.length() && isWordChar(text.charAt(end))) end++; + return new TextSyntaxContext(SyntaxElementType.WORD, start, end, null); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/TagAttributeRegistry.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/TagAttributeRegistry.java new file mode 100644 index 00000000..39bd6d55 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/TagAttributeRegistry.java @@ -0,0 +1,696 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.hfstudio.guidenh.guide.document.block.AlignItems; +import com.hfstudio.guidenh.guide.document.block.chart.ChartLabelPosition; +import com.hfstudio.guidenh.guide.document.block.chart.ChartLegendPosition; +import com.hfstudio.guidenh.guide.document.block.chart.CornerLegendPosition; +import com.hfstudio.guidenh.guide.document.block.functiongraph.AutoPointLabelMode; + +public class TagAttributeRegistry { + + private static final Map> registry = new LinkedHashMap<>(); + + private TagAttributeRegistry() {} + + public static void register(String tagName, AttributeSpec... specs) { + registry.computeIfAbsent(tagName, k -> new ArrayList<>()) + .addAll(Arrays.asList(specs)); + } + + public static List get(String tagName) { + return Collections.unmodifiableList(registry.getOrDefault(tagName, Collections.emptyList())); + } + + public static Set getRegisteredTags() { + return Collections.unmodifiableSet(registry.keySet()); + } + + /** Populate the registry with all known tag-to-attribute mappings. */ + public static void initialize() { + // Inline/Flow tags + register( + "ItemImage", + new AttributeSpec("id", AttrType.ITEM_ID), + new AttributeSpec("ore", AttrType.ORE_DICT), + new AttributeSpec("scale", AttrType.FLOAT), + new AttributeSpec("yOffset", AttrType.INT), + new AttributeSpec("labelYOffset", AttrType.INT), + new AttributeSpec("noTooltip", AttrType.BOOLEAN), + new AttributeSpec("showTooltip", AttrType.BOOLEAN), + new AttributeSpec("showIcon", AttrType.STRING), + new AttributeSpec("label", AttrType.STRING), + new AttributeSpec("format", AttrType.FORMAT_PATTERN)); + register( + "ItemLink", + new AttributeSpec("id", AttrType.ITEM_ID), + new AttributeSpec("ore", AttrType.ORE_DICT), + new AttributeSpec("noTooltip", AttrType.BOOLEAN), + new AttributeSpec("showTooltip", AttrType.BOOLEAN), + new AttributeSpec("showText", AttrType.BOOLEAN), + new AttributeSpec("showIcon", AttrType.STRING), + new AttributeSpec("linksTo", AttrType.PAGE_PATH)); + register( + "BlockImage", + new AttributeSpec("id", AttrType.BLOCK_ID), + new AttributeSpec("ore", AttrType.ORE_DICT), + new AttributeSpec("scale", AttrType.FLOAT), + new AttributeSpec("wrap", AttrType.STRING), + new AttributeSpec("align", AttrType.STRING), + new AttributeSpec("float", AttrType.STRING)); + register( + "FloatingImage", + new AttributeSpec("src", AttrType.FILE_PATH), + new AttributeSpec("align", AttrType.STRING), + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT)); + register("Color", new AttributeSpec("id", AttrType.COLOR), new AttributeSpec("color", AttrType.COLOR)); + register("KeyBind", new AttributeSpec("id", AttrType.KEY_BIND), new AttributeSpec("action", AttrType.STRING)); + register( + "CommandLink", + new AttributeSpec("command", AttrType.COMMAND), + new AttributeSpec("close", AttrType.BOOLEAN), + new AttributeSpec("title", AttrType.STRING)); + register( + "Recipe", + new AttributeSpec("id", AttrType.ITEM_ID), + new AttributeSpec("fallbackText", AttrType.STRING), + new AttributeSpec("handlerName", AttrType.STRING), + new AttributeSpec("handlerId", AttrType.STRING), + new AttributeSpec("handlerOrder", AttrType.INT), + new AttributeSpec("input", AttrType.STRING), + new AttributeSpec("output", AttrType.STRING), + new AttributeSpec("limit", AttrType.INT)); + register( + "RecipeFor", + new AttributeSpec("id", AttrType.ITEM_ID), + new AttributeSpec("fallbackText", AttrType.STRING), + new AttributeSpec("handlerName", AttrType.STRING), + new AttributeSpec("handlerId", AttrType.STRING), + new AttributeSpec("handlerOrder", AttrType.INT), + new AttributeSpec("input", AttrType.STRING), + new AttributeSpec("output", AttrType.STRING), + new AttributeSpec("limit", AttrType.INT)); + register( + "RecipesFor", + new AttributeSpec("id", AttrType.ITEM_ID), + new AttributeSpec("fallbackText", AttrType.STRING), + new AttributeSpec("handlerName", AttrType.STRING), + new AttributeSpec("handlerId", AttrType.STRING), + new AttributeSpec("handlerOrder", AttrType.INT), + new AttributeSpec("input", AttrType.STRING), + new AttributeSpec("output", AttrType.STRING), + new AttributeSpec("limit", AttrType.INT)); + register( + "SubPages", + new AttributeSpec("id", AttrType.PAGE_PATH), + new AttributeSpec("alphabetical", AttrType.BOOLEAN)); + register("CategoryIndex", new AttributeSpec("category", AttrType.STRING)); + register("Structure", new AttributeSpec("width", AttrType.INT), new AttributeSpec("height", AttrType.INT)); + register( + "Mermaid", + new AttributeSpec("src", AttrType.FILE_PATH), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT)); + register( + "CsvTable", + new AttributeSpec("src", AttrType.FILE_PATH), + new AttributeSpec("header", AttrType.BOOLEAN), + new AttributeSpec("widths", AttrType.STRING)); + register("details", new AttributeSpec("open", AttrType.BOOLEAN)); + register( + "ContentTabs", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("default", AttrType.STRING), + new AttributeSpec("defaultIndex", AttrType.INT), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("icon", AttrType.STRING), + new AttributeSpec("iconPng", AttrType.FILE_PATH), + new AttributeSpec("icon_png", AttrType.FILE_PATH), + new AttributeSpec("iconItem", AttrType.ITEM_ID), + new AttributeSpec("icon_item", AttrType.ITEM_ID)); + register("Tab", new AttributeSpec("title", AttrType.STRING)); + register( + "Row", + new AttributeSpec("gap", AttrType.INT), + new AttributeSpec("alignItems", AttrType.ENUM, AlignItems.class), + new AttributeSpec("fullWidth", AttrType.BOOLEAN), + new AttributeSpec("width", AttrType.INT)); + register( + "Column", + new AttributeSpec("gap", AttrType.INT), + new AttributeSpec("alignItems", AttrType.ENUM, AlignItems.class), + new AttributeSpec("fullWidth", AttrType.BOOLEAN), + new AttributeSpec("width", AttrType.INT)); + register( + "a", + new AttributeSpec("name", AttrType.STRING), + new AttributeSpec("href", AttrType.PAGE_PATH), + new AttributeSpec("title", AttrType.STRING)); + register("br", new AttributeSpec("clear", AttrType.STRING)); + register("ImportPonder", new AttributeSpec("src", AttrType.FILE_PATH)); + register("RemoveBlocks", new AttributeSpec("id", AttrType.BLOCK_ID)); + register( + "IsometricCamera", + new AttributeSpec("yaw", AttrType.FLOAT), + new AttributeSpec("pitch", AttrType.FLOAT), + new AttributeSpec("roll", AttrType.FLOAT)); + register("Tooltip", new AttributeSpec("label", AttrType.STRING)); + register("mark", new AttributeSpec("color", AttrType.COLOR)); + register("FileTree", new AttributeSpec("indent", AttrType.INT), new AttributeSpec("gap", AttrType.INT)); + register("ItemGrid"); // no attributes - uses child elements + register("FootnoteList", new AttributeSpec("width", AttrType.INT)); + + // Charts share CommonChartAttrs. + register( + "BarChart", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("titleColor", AttrType.COLOR), + new AttributeSpec("labelColor", AttrType.COLOR), + new AttributeSpec("legend", AttrType.ENUM, ChartLegendPosition.class), + new AttributeSpec("labelPosition", AttrType.ENUM, ChartLabelPosition.class), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR), + new AttributeSpec("categories", AttrType.STRING), + new AttributeSpec("xAxisLabel", AttrType.STRING), + new AttributeSpec("yAxisLabel", AttrType.STRING), + new AttributeSpec("xAxisMin", AttrType.FLOAT), + new AttributeSpec("xAxisMax", AttrType.FLOAT), + new AttributeSpec("yAxisMin", AttrType.FLOAT), + new AttributeSpec("yAxisMax", AttrType.FLOAT), + new AttributeSpec("xAxisStep", AttrType.FLOAT), + new AttributeSpec("yAxisStep", AttrType.FLOAT), + new AttributeSpec("xAxisUnit", AttrType.STRING), + new AttributeSpec("yAxisUnit", AttrType.STRING), + new AttributeSpec("xAxisTickFormat", AttrType.STRING), + new AttributeSpec("yAxisTickFormat", AttrType.STRING), + new AttributeSpec("showXGrid", AttrType.BOOLEAN), + new AttributeSpec("showYGrid", AttrType.BOOLEAN), + new AttributeSpec("xGridColor", AttrType.COLOR), + new AttributeSpec("yGridColor", AttrType.COLOR), + new AttributeSpec("barWidthRatio", AttrType.FLOAT)); + register( + "ColumnChart", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("titleColor", AttrType.COLOR), + new AttributeSpec("labelColor", AttrType.COLOR), + new AttributeSpec("legend", AttrType.ENUM, ChartLegendPosition.class), + new AttributeSpec("labelPosition", AttrType.ENUM, ChartLabelPosition.class), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR), + new AttributeSpec("categories", AttrType.STRING), + new AttributeSpec("xAxisLabel", AttrType.STRING), + new AttributeSpec("yAxisLabel", AttrType.STRING), + new AttributeSpec("xAxisMin", AttrType.FLOAT), + new AttributeSpec("xAxisMax", AttrType.FLOAT), + new AttributeSpec("yAxisMin", AttrType.FLOAT), + new AttributeSpec("yAxisMax", AttrType.FLOAT), + new AttributeSpec("xAxisStep", AttrType.FLOAT), + new AttributeSpec("yAxisStep", AttrType.FLOAT), + new AttributeSpec("xAxisUnit", AttrType.STRING), + new AttributeSpec("yAxisUnit", AttrType.STRING), + new AttributeSpec("xAxisTickFormat", AttrType.STRING), + new AttributeSpec("yAxisTickFormat", AttrType.STRING), + new AttributeSpec("showXGrid", AttrType.BOOLEAN), + new AttributeSpec("showYGrid", AttrType.BOOLEAN), + new AttributeSpec("xGridColor", AttrType.COLOR), + new AttributeSpec("yGridColor", AttrType.COLOR), + new AttributeSpec("barWidthRatio", AttrType.FLOAT)); + register( + "LineChart", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("titleColor", AttrType.COLOR), + new AttributeSpec("labelColor", AttrType.COLOR), + new AttributeSpec("legend", AttrType.ENUM, ChartLegendPosition.class), + new AttributeSpec("labelPosition", AttrType.ENUM, ChartLabelPosition.class), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR), + new AttributeSpec("categories", AttrType.STRING), + new AttributeSpec("xAxisLabel", AttrType.STRING), + new AttributeSpec("yAxisLabel", AttrType.STRING), + new AttributeSpec("xAxisMin", AttrType.FLOAT), + new AttributeSpec("xAxisMax", AttrType.FLOAT), + new AttributeSpec("yAxisMin", AttrType.FLOAT), + new AttributeSpec("yAxisMax", AttrType.FLOAT), + new AttributeSpec("xAxisStep", AttrType.FLOAT), + new AttributeSpec("yAxisStep", AttrType.FLOAT), + new AttributeSpec("xAxisUnit", AttrType.STRING), + new AttributeSpec("yAxisUnit", AttrType.STRING), + new AttributeSpec("xAxisTickFormat", AttrType.STRING), + new AttributeSpec("yAxisTickFormat", AttrType.STRING), + new AttributeSpec("showXGrid", AttrType.BOOLEAN), + new AttributeSpec("showYGrid", AttrType.BOOLEAN), + new AttributeSpec("xGridColor", AttrType.COLOR), + new AttributeSpec("yGridColor", AttrType.COLOR), + new AttributeSpec("numericX", AttrType.BOOLEAN), + new AttributeSpec("showPoints", AttrType.BOOLEAN)); + register( + "ScatterChart", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("titleColor", AttrType.COLOR), + new AttributeSpec("labelColor", AttrType.COLOR), + new AttributeSpec("legend", AttrType.ENUM, ChartLegendPosition.class), + new AttributeSpec("labelPosition", AttrType.ENUM, ChartLabelPosition.class), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR), + new AttributeSpec("xAxisLabel", AttrType.STRING), + new AttributeSpec("yAxisLabel", AttrType.STRING), + new AttributeSpec("xAxisMin", AttrType.FLOAT), + new AttributeSpec("xAxisMax", AttrType.FLOAT), + new AttributeSpec("yAxisMin", AttrType.FLOAT), + new AttributeSpec("yAxisMax", AttrType.FLOAT), + new AttributeSpec("xAxisStep", AttrType.FLOAT), + new AttributeSpec("yAxisStep", AttrType.FLOAT), + new AttributeSpec("xAxisUnit", AttrType.STRING), + new AttributeSpec("yAxisUnit", AttrType.STRING), + new AttributeSpec("xAxisTickFormat", AttrType.STRING), + new AttributeSpec("yAxisTickFormat", AttrType.STRING), + new AttributeSpec("showXGrid", AttrType.BOOLEAN), + new AttributeSpec("showYGrid", AttrType.BOOLEAN), + new AttributeSpec("xGridColor", AttrType.COLOR), + new AttributeSpec("yGridColor", AttrType.COLOR)); + register( + "PieChart", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("titleColor", AttrType.COLOR), + new AttributeSpec("labelColor", AttrType.COLOR), + new AttributeSpec("legend", AttrType.ENUM, ChartLegendPosition.class), + new AttributeSpec("labelPosition", AttrType.ENUM, ChartLabelPosition.class), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR), + new AttributeSpec("startAngle", AttrType.FLOAT), + new AttributeSpec("clockwise", AttrType.BOOLEAN)); + + // Chart child tags. + register( + "Series", + new AttributeSpec("name", AttrType.STRING), + new AttributeSpec("data", AttrType.STRING), + new AttributeSpec("points", AttrType.STRING), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("icon", AttrType.ITEM_ID), + new AttributeSpec("iconImage", AttrType.FILE_PATH), + new AttributeSpec("tooltip", AttrType.STRING)); + register( + "LineSeries", + new AttributeSpec("name", AttrType.STRING), + new AttributeSpec("data", AttrType.STRING), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("icon", AttrType.ITEM_ID), + new AttributeSpec("iconImage", AttrType.FILE_PATH), + new AttributeSpec("tooltip", AttrType.STRING)); + register( + "Slice", + new AttributeSpec("label", AttrType.STRING), + new AttributeSpec("value", AttrType.FLOAT), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("icon", AttrType.ITEM_ID), + new AttributeSpec("iconImage", AttrType.FILE_PATH), + new AttributeSpec("tooltip", AttrType.STRING)); + register( + "PieInset", + new AttributeSpec("size", AttrType.FLOAT), + new AttributeSpec("position", AttrType.STRING), + new AttributeSpec("startAngleDeg", AttrType.FLOAT), + new AttributeSpec("direction", AttrType.STRING), + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("titleColor", AttrType.COLOR)); + + // FunctionGraph child tags. + register( + "Plot", + new AttributeSpec("expr", AttrType.EXPRESSION), + new AttributeSpec("inverse", AttrType.BOOLEAN), + new AttributeSpec("domain", AttrType.DOMAIN), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("label", AttrType.STRING), + new AttributeSpec("pointEveryX", AttrType.FLOAT), + new AttributeSpec("pointEveryY", AttrType.FLOAT), + new AttributeSpec("autoPointLabel", AttrType.ENUM, AutoPointLabelMode.class), + new AttributeSpec("autoPointColor", AttrType.COLOR)); + register( + "Point", + new AttributeSpec("x", AttrType.FLOAT), + new AttributeSpec("y", AttrType.FLOAT), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("label", AttrType.STRING), + new AttributeSpec("plot", AttrType.INT), + new AttributeSpec("atX", AttrType.FLOAT), + new AttributeSpec("atY", AttrType.FLOAT)); + + // Existing registrations with extended attributes. + + // GameScene: add camera attributes + register( + "Scene", + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("zoom", AttrType.FLOAT), + new AttributeSpec("perspective", AttrType.STRING), + new AttributeSpec("rotateX", AttrType.FLOAT), + new AttributeSpec("rotateY", AttrType.FLOAT), + new AttributeSpec("rotateZ", AttrType.FLOAT), + new AttributeSpec("offsetX", AttrType.FLOAT), + new AttributeSpec("offsetY", AttrType.FLOAT), + new AttributeSpec("centerX", AttrType.FLOAT), + new AttributeSpec("centerY", AttrType.FLOAT), + new AttributeSpec("centerZ", AttrType.FLOAT), + new AttributeSpec("interactive", AttrType.BOOLEAN), + new AttributeSpec("allowLayerSlider", AttrType.BOOLEAN), + new AttributeSpec("gridButtonEnabled", AttrType.BOOLEAN), + new AttributeSpec("showGrid", AttrType.BOOLEAN)); + + // GameScene: also register as "GameScene" with same attrs (SceneTagCompiler handles both) + register( + "GameScene", + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("zoom", AttrType.FLOAT), + new AttributeSpec("perspective", AttrType.STRING), + new AttributeSpec("rotateX", AttrType.FLOAT), + new AttributeSpec("rotateY", AttrType.FLOAT), + new AttributeSpec("rotateZ", AttrType.FLOAT), + new AttributeSpec("offsetX", AttrType.FLOAT), + new AttributeSpec("offsetY", AttrType.FLOAT), + new AttributeSpec("centerX", AttrType.FLOAT), + new AttributeSpec("centerY", AttrType.FLOAT), + new AttributeSpec("centerZ", AttrType.FLOAT), + new AttributeSpec("interactive", AttrType.BOOLEAN), + new AttributeSpec("allowLayerSlider", AttrType.BOOLEAN), + new AttributeSpec("gridButtonEnabled", AttrType.BOOLEAN), + new AttributeSpec("showGrid", AttrType.BOOLEAN)); + + // Function: add missing container + plot attrs + register( + "Function", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("axisColor", AttrType.COLOR), + new AttributeSpec("gridColor", AttrType.COLOR), + new AttributeSpec("showGrid", AttrType.BOOLEAN), + new AttributeSpec("showAxes", AttrType.BOOLEAN), + new AttributeSpec("xMin", AttrType.FLOAT), + new AttributeSpec("xMax", AttrType.FLOAT), + new AttributeSpec("yMin", AttrType.FLOAT), + new AttributeSpec("yMax", AttrType.FLOAT), + new AttributeSpec("xStep", AttrType.FLOAT), + new AttributeSpec("yStep", AttrType.FLOAT), + new AttributeSpec("xRange", AttrType.STRING), + new AttributeSpec("yRange", AttrType.STRING), + new AttributeSpec("quadrants", AttrType.STRING), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR), + new AttributeSpec("expr", AttrType.EXPRESSION), + new AttributeSpec("inverse", AttrType.BOOLEAN), + new AttributeSpec("domain", AttrType.DOMAIN), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("label", AttrType.STRING), + new AttributeSpec("pointEveryX", AttrType.FLOAT), + new AttributeSpec("pointEveryY", AttrType.FLOAT), + new AttributeSpec("autoPointLabel", AttrType.ENUM, AutoPointLabelMode.class), + new AttributeSpec("autoPointColor", AttrType.COLOR)); + + // FunctionGraph: add missing container attrs + register( + "FunctionGraph", + new AttributeSpec("title", AttrType.STRING), + new AttributeSpec("width", AttrType.INT), + new AttributeSpec("height", AttrType.INT), + new AttributeSpec("background", AttrType.COLOR), + new AttributeSpec("border", AttrType.COLOR), + new AttributeSpec("axisColor", AttrType.COLOR), + new AttributeSpec("gridColor", AttrType.COLOR), + new AttributeSpec("showGrid", AttrType.BOOLEAN), + new AttributeSpec("showAxes", AttrType.BOOLEAN), + new AttributeSpec("xMin", AttrType.FLOAT), + new AttributeSpec("xMax", AttrType.FLOAT), + new AttributeSpec("yMin", AttrType.FLOAT), + new AttributeSpec("yMax", AttrType.FLOAT), + new AttributeSpec("xStep", AttrType.FLOAT), + new AttributeSpec("yStep", AttrType.FLOAT), + new AttributeSpec("xRange", AttrType.STRING), + new AttributeSpec("yRange", AttrType.STRING), + new AttributeSpec("quadrants", AttrType.STRING), + new AttributeSpec("cornerLegend", AttrType.ENUM, CornerLegendPosition.class), + new AttributeSpec("cornerLegendWidth", AttrType.INT), + new AttributeSpec("cornerLegendHeight", AttrType.INT), + new AttributeSpec("cornerLegendBackground", AttrType.COLOR)); + + // Entity: add missing rotation attrs + register( + "Entity", + new AttributeSpec("id", AttrType.ENTITY_ID), + new AttributeSpec("data", AttrType.SNBT), + new AttributeSpec("name", AttrType.STRING), + new AttributeSpec("uuid", AttrType.STRING), + new AttributeSpec("showName", AttrType.BOOLEAN), + new AttributeSpec("showCape", AttrType.BOOLEAN), + new AttributeSpec("baby", AttrType.BOOLEAN), + new AttributeSpec("x", AttrType.FLOAT), + new AttributeSpec("y", AttrType.FLOAT), + new AttributeSpec("z", AttrType.FLOAT), + new AttributeSpec("rotationY", AttrType.FLOAT), + new AttributeSpec("rotationX", AttrType.FLOAT), + new AttributeSpec("headRotation", AttrType.STRING), + new AttributeSpec("leftArmRotation", AttrType.STRING), + new AttributeSpec("rightArmRotation", AttrType.STRING), + new AttributeSpec("leftLegRotation", AttrType.STRING), + new AttributeSpec("rightLegRotation", AttrType.STRING), + new AttributeSpec("capeRotation", AttrType.STRING)); + + // Latex: add missing attrs + register( + "Latex", + new AttributeSpec("formula", AttrType.STRING), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("scale", AttrType.FLOAT), + new AttributeSpec("sourceScale", AttrType.FLOAT), + new AttributeSpec("showTooltip", AttrType.BOOLEAN), + new AttributeSpec("valign", AttrType.STRING), + new AttributeSpec("offsetX", AttrType.INT), + new AttributeSpec("offsetY", AttrType.INT)); + + // ImportStructureLib: add offset attrs + register( + "ImportStructureLib", + new AttributeSpec("controller", AttrType.STRING), + new AttributeSpec("piece", AttrType.STRING), + new AttributeSpec("channel", AttrType.STRING), + new AttributeSpec("facing", AttrType.STRING), + new AttributeSpec("rotation", AttrType.STRING), + new AttributeSpec("flip", AttrType.STRING), + new AttributeSpec("offsetX", AttrType.INT), + new AttributeSpec("offsetY", AttrType.INT), + new AttributeSpec("offsetZ", AttrType.INT)); + register("Tier", new AttributeSpec("value", AttrType.INT), new AttributeSpec("expr", AttrType.INT)); + register( + "Channel", + new AttributeSpec("name", AttrType.STRING), + new AttributeSpec("id", AttrType.STRING), + new AttributeSpec("value", AttrType.INT), + new AttributeSpec("expr", AttrType.INT)); + register("Facing", new AttributeSpec("value", AttrType.STRING)); + register("Rotation", new AttributeSpec("value", AttrType.STRING)); + register("Flip", new AttributeSpec("value", AttrType.STRING)); + register("Orientation", new AttributeSpec("value", AttrType.STRING)); + register("GregTechActiveController"); + register("GtActiveController"); + register("GregTechPlaceHatches"); + register("GtPlaceHatches"); + + // PlaceBlock: add dx/dy/dz + register( + "PlaceBlock", + new AttributeSpec("id", AttrType.BLOCK_ID), + new AttributeSpec("nbt", AttrType.SNBT), + new AttributeSpec("x", AttrType.INT), + new AttributeSpec("y", AttrType.INT), + new AttributeSpec("z", AttrType.INT), + new AttributeSpec("dx", AttrType.INT), + new AttributeSpec("dy", AttrType.INT), + new AttributeSpec("dz", AttrType.INT)); + + // ReplaceBlock: add bounds attrs + register( + "ReplaceBlock", + new AttributeSpec("from", AttrType.BLOCK_ID), + new AttributeSpec("to", AttrType.BLOCK_ID), + new AttributeSpec("from_nbt", AttrType.SNBT), + new AttributeSpec("to_nbt", AttrType.SNBT), + new AttributeSpec("x", AttrType.INT), + new AttributeSpec("y", AttrType.INT), + new AttributeSpec("z", AttrType.INT), + new AttributeSpec("dx", AttrType.INT), + new AttributeSpec("dy", AttrType.INT), + new AttributeSpec("dz", AttrType.INT)); + + // ImportStructure: fix types + add x/y/z + register( + "ImportStructure", + new AttributeSpec("src", AttrType.FILE_PATH), + new AttributeSpec("x", AttrType.INT), + new AttributeSpec("y", AttrType.INT), + new AttributeSpec("z", AttrType.INT), + new AttributeSpec("offsetX", AttrType.INT), + new AttributeSpec("offsetY", AttrType.INT), + new AttributeSpec("offsetZ", AttrType.INT)); + + // Block scene element + register( + "Block", + new AttributeSpec("id", AttrType.BLOCK_ID), + new AttributeSpec("ore", AttrType.ORE_DICT), + new AttributeSpec("x", AttrType.INT), + new AttributeSpec("y", AttrType.INT), + new AttributeSpec("z", AttrType.INT), + new AttributeSpec("meta", AttrType.INT), + new AttributeSpec("facing", AttrType.STRING), + new AttributeSpec("nbt", AttrType.SNBT)); + + // Annotation tags + register( + "BlockAnnotation", + new AttributeSpec("pos", AttrType.VECTOR3), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("thickness", AttrType.FLOAT), + new AttributeSpec("alwaysOnTop", AttrType.BOOLEAN)); + + register( + "BoxAnnotation", + new AttributeSpec("min", AttrType.VECTOR3), + new AttributeSpec("max", AttrType.VECTOR3), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("thickness", AttrType.FLOAT), + new AttributeSpec("alwaysOnTop", AttrType.BOOLEAN)); + + register( + "LineAnnotation", + new AttributeSpec("points", AttrType.STRING), + new AttributeSpec("from", AttrType.VECTOR3), + new AttributeSpec("to", AttrType.VECTOR3), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("thickness", AttrType.FLOAT), + new AttributeSpec("alwaysOnTop", AttrType.BOOLEAN), + new AttributeSpec("showPoints", AttrType.BOOLEAN), + new AttributeSpec("pointColor", AttrType.COLOR), + new AttributeSpec("pointSize", AttrType.FLOAT), + new AttributeSpec("arrow", AttrType.STRING)); + + register( + "DiamondAnnotation", + new AttributeSpec("pos", AttrType.VECTOR3), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("alwaysOnTop", AttrType.BOOLEAN)); + + register( + "TextAnnotation", + new AttributeSpec("text", AttrType.STRING), + new AttributeSpec("color", AttrType.COLOR), + new AttributeSpec("maxWidth", AttrType.INT), + new AttributeSpec("backgroundAlpha", AttrType.INT), + new AttributeSpec("independent", AttrType.BOOLEAN), + new AttributeSpec("yOffset", AttrType.INT), + new AttributeSpec("pos", AttrType.VECTOR3)); + + register("BlockAnnotationTemplate", new AttributeSpec("id", AttrType.STRING)); + + // Sound tags (share GuideSoundParsers.parseAttributes) + register( + "PlaySound", + new AttributeSpec("sound", AttrType.STRING), + new AttributeSpec("src", AttrType.FILE_PATH), + new AttributeSpec("volume", AttrType.FLOAT), + new AttributeSpec("pitch", AttrType.FLOAT), + new AttributeSpec("cooldown", AttrType.INT), + new AttributeSpec("radius", AttrType.FLOAT), + new AttributeSpec("minVolume", AttrType.FLOAT), + new AttributeSpec("x", AttrType.FLOAT), + new AttributeSpec("y", AttrType.FLOAT), + new AttributeSpec("z", AttrType.FLOAT), + new AttributeSpec("trigger", AttrType.STRING)); + + register( + "SoundLink", + new AttributeSpec("sound", AttrType.STRING), + new AttributeSpec("src", AttrType.FILE_PATH), + new AttributeSpec("volume", AttrType.FLOAT), + new AttributeSpec("pitch", AttrType.FLOAT), + new AttributeSpec("cooldown", AttrType.INT), + new AttributeSpec("radius", AttrType.FLOAT), + new AttributeSpec("minVolume", AttrType.FLOAT), + new AttributeSpec("x", AttrType.FLOAT), + new AttributeSpec("y", AttrType.FLOAT), + new AttributeSpec("z", AttrType.FLOAT)); + + // Quest integration tags + register("QuestLink", new AttributeSpec("id", AttrType.STRING), new AttributeSpec("text", AttrType.STRING)); + + register( + "QuestCard", + new AttributeSpec("id", AttrType.STRING), + new AttributeSpec("show_desc", AttrType.STRING)); + + // Scene block stats + register( + "BlockStats", + new AttributeSpec("visible", AttrType.BOOLEAN), + new AttributeSpec("buttonEnabled", AttrType.BOOLEAN), + new AttributeSpec("mode", AttrType.STRING), + new AttributeSpec("corner", AttrType.STRING), + new AttributeSpec("dock", AttrType.STRING), + new AttributeSpec("showNames", AttrType.BOOLEAN), + new AttributeSpec("filterMode", AttrType.STRING), + new AttributeSpec("filter", AttrType.STRING), + new AttributeSpec("maxWidth", AttrType.INT), + new AttributeSpec("maxHeight", AttrType.INT)); + + register( + "BlockStat", + new AttributeSpec("id", AttrType.BLOCK_ID), + new AttributeSpec("item", AttrType.ITEM_ID), + new AttributeSpec("count", AttrType.FLOAT)); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/TextSyntaxContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/TextSyntaxContext.java new file mode 100644 index 00000000..07fbb7ff --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/TextSyntaxContext.java @@ -0,0 +1,41 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete; + +import org.jetbrains.annotations.Nullable; + +public class TextSyntaxContext { + + private final SyntaxElementType elementType; + private final int elementStart; + private final int elementEnd; + @Nullable + private final AutocompleteContext autocomplete; + + public TextSyntaxContext(SyntaxElementType elementType, int elementStart, int elementEnd, + @Nullable AutocompleteContext autocomplete) { + this.elementType = elementType; + this.elementStart = elementStart; + this.elementEnd = elementEnd; + this.autocomplete = autocomplete; + } + + public SyntaxElementType getElementType() { + return elementType; + } + + public int getElementStart() { + return elementStart; + } + + public int getElementEnd() { + return elementEnd; + } + + @Nullable + public AutocompleteContext getAutocomplete() { + return autocomplete; + } + + public boolean shouldAutocomplete() { + return autocomplete != null; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AnchorProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AnchorProvider.java new file mode 100644 index 00000000..071c2728 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AnchorProvider.java @@ -0,0 +1,57 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests heading anchors from the current document for href="#..." attributes. */ +public class AnchorProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.singleton(AutocompleteKey.forValue("a", "href")); + + private static final Pattern HEADING = Pattern.compile("^#{1,6}\\s+(.+)$", Pattern.MULTILINE); + + @Nullable + private static volatile String documentText; + + /** Update the document text for heading extraction. */ + public static void setDocumentText(@Nullable String text) { + documentText = text; + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText(); + if (partial == null || !partial.startsWith("#")) return Collections.emptyList(); + String query = partial.substring(1) + .toLowerCase(); // strip leading # + + if (documentText == null) return Collections.emptyList(); + List results = new ArrayList<>(); + Matcher m = HEADING.matcher(documentText); + while (m.find()) { + if (results.size() >= limit) break; + String heading = m.group(1) + .trim(); + String anchor = heading.toLowerCase() + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-|-$", ""); + if (query.isEmpty() || anchor.contains(query)) { + results.add(new RegistryCandidate("#" + anchor, heading)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AttributeNameProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AttributeNameProvider.java new file mode 100644 index 00000000..f6afb7c4 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AttributeNameProvider.java @@ -0,0 +1,49 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AttributeSpec; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TagAttributeRegistry; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxAttrNameContext; + +public class AttributeNameProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.singleton(AutocompleteKey.forAttr("*")); + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + private static volatile boolean enabled = true; + + public static void setEnabled(boolean value) { + enabled = value; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!enabled) return Collections.emptyList(); + if (!(ctx instanceof MdxAttrNameContext)) return Collections.emptyList(); + MdxAttrNameContext mdx = (MdxAttrNameContext) ctx; + + List specs = TagAttributeRegistry.get(mdx.getTagName()); + String partial = mdx.getPartialText() + .toLowerCase(); + + List results = new ArrayList<>(); + for (AttributeSpec spec : specs) { + if (results.size() >= limit) break; + if (partial.isEmpty() || spec.getName() + .toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(spec.getName())); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AttributePresetValueProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AttributePresetValueProvider.java new file mode 100644 index 00000000..2a37ada8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AttributePresetValueProvider.java @@ -0,0 +1,65 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxValueContext; + +public class AttributePresetValueProvider implements AutocompleteProvider { + + private static final Map VALUES = new LinkedHashMap<>(); + + static { + VALUES.put("showIcon", new String[] { "left", "right", "true", "false" }); + VALUES.put("showTooltip", new String[] { "true", "false" }); + VALUES.put("showText", new String[] { "true", "false" }); + VALUES.put("noTooltip", new String[] { "true", "false" }); + VALUES.put("wrap", new String[] { "inline", "square", "tight", "through" }); + VALUES.put("align", new String[] { "left", "center", "right" }); + VALUES.put("float", new String[] { "none", "left", "right" }); + VALUES.put("clear", new String[] { "none", "left", "right", "all" }); + VALUES.put("valign", new String[] { "baseline", "top", "middle", "bottom" }); + VALUES.put("position", new String[] { "topLeft", "topRight", "bottomLeft", "bottomRight", "center" }); + VALUES.put("direction", new String[] { "clockwise", "counterclockwise" }); + VALUES.put("perspective", new String[] { "isometric", "front", "back", "left", "right", "top", "bottom" }); + VALUES.put("facing", new String[] { "north", "south", "west", "east", "up", "down" }); + VALUES.put("rotation", new String[] { "normal", "clockwise_90", "clockwise_180", "counterclockwise_90" }); + VALUES.put("flip", new String[] { "none", "front_back", "left_right" }); + VALUES.put("background", new String[] { "transparent", "checker", "solid" }); + VALUES.put("trigger", new String[] { "click", "hover" }); + VALUES.put("target", new String[] { "self", "blank" }); + VALUES.put("quadrants", new String[] { "all", "positive", "top", "right" }); + } + + @Override + public Set getSupportedKeys() { + return Collections.singleton(AutocompleteKey.forValue("*", "*")); + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!(ctx instanceof MdxValueContext)) { + return Collections.emptyList(); + } + String[] values = VALUES.get(((MdxValueContext) ctx).getAttrName()); + if (values == null) { + return Collections.emptyList(); + } + String partial = ctx.getPartialText(); + String lower = partial != null ? partial.toLowerCase() : ""; + List results = new ArrayList<>(); + for (String value : values) { + if (results.size() >= limit) break; + if (lower.isEmpty() || value.toLowerCase() + .startsWith(lower)) { + results.add(new TextCandidate(value)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteCandidate.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteCandidate.java new file mode 100644 index 00000000..a11d53b1 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteCandidate.java @@ -0,0 +1,21 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import net.minecraft.client.gui.FontRenderer; + +public interface AutocompleteCandidate { + + String displayText(); + + String replacementText(); + + default int renderHeight() { + return 14; + } + + /** Width hint for popup sizing. Default 0 means use displayText width. */ + default int renderWidth(FontRenderer fontRenderer) { + return 0; + } + + void render(FontRenderer fontRenderer, int x, int y, int width, boolean hovered); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteKey.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteKey.java new file mode 100644 index 00000000..5bda3172 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteKey.java @@ -0,0 +1,91 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.Objects; + +public class AutocompleteKey { + + public enum MatchType { + TAG_NAME, + ATTR_NAME, + ATTR_VALUE, + FENCE_LANGUAGE + } + + private final MatchType type; + private final String tagName; + private final String attrName; + + private AutocompleteKey(MatchType type, String tagName, String attrName) { + this.type = type; + this.tagName = tagName; + this.attrName = attrName; + } + + public static AutocompleteKey forTag() { + return new AutocompleteKey(MatchType.TAG_NAME, null, null); + } + + public static AutocompleteKey forTag(String parentTagName) { + return new AutocompleteKey(MatchType.TAG_NAME, parentTagName, null); + } + + public static AutocompleteKey forAttr(String tagName) { + return new AutocompleteKey(MatchType.ATTR_NAME, Objects.requireNonNull(tagName), null); + } + + public static AutocompleteKey forValue(String tagName, String attrName) { + return new AutocompleteKey( + MatchType.ATTR_VALUE, + Objects.requireNonNull(tagName), + Objects.requireNonNull(attrName)); + } + + public static AutocompleteKey forFenceLanguage() { + return new AutocompleteKey(MatchType.FENCE_LANGUAGE, null, null); + } + + public MatchType getType() { + return type; + } + + public String getTagName() { + return tagName; + } + + public String getAttrName() { + return attrName; + } + + public boolean matches(MatchType queryType, String queryTag, String queryAttr) { + if (type != queryType) return false; + switch (type) { + case TAG_NAME: + case FENCE_LANGUAGE: + return true; + case ATTR_NAME: + return tagName.equals("*") || tagName.equals(queryTag); + case ATTR_VALUE: + return (tagName.equals("*") || tagName.equals(queryTag)) + && (attrName.equals("*") || attrName.equals(queryAttr)); + default: + return false; + } + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof AutocompleteKey)) return false; + AutocompleteKey that = (AutocompleteKey) o; + return type == that.type && Objects.equals(tagName, that.tagName) && Objects.equals(attrName, that.attrName); + } + + @Override + public int hashCode() { + return Objects.hash(type, tagName, attrName); + } + + @Override + public String toString() { + return "AutocompleteKey{" + type + ", " + tagName + ", " + attrName + "}"; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteProvider.java new file mode 100644 index 00000000..19acf2e5 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteProvider.java @@ -0,0 +1,13 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public interface AutocompleteProvider { + + Set getSupportedKeys(); + + List provide(AutocompleteContext ctx, int limit); +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteProviders.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteProviders.java new file mode 100644 index 00000000..45b97ebb --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/AutocompleteProviders.java @@ -0,0 +1,134 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.FenceLanguageContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.FrontmatterContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxAttrNameContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxValueContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.TagStartContext; + +public class AutocompleteProviders { + + private static final List providers = new ArrayList<>(); + private static final Map> providersByKey = new HashMap<>(); + + private AutocompleteProviders() {} + + public static void register(AutocompleteProvider provider) { + providers.add(provider); + for (AutocompleteKey key : provider.getSupportedKeys()) { + providersByKey + .computeIfAbsent( + toLookupKey(key.getType(), key.getTagName(), key.getAttrName()), + ignored -> new ArrayList<>()) + .add(provider); + } + } + + public static List query(AutocompleteContext ctx, int limit) { + List results = new ArrayList<>(); + for (AutocompleteProvider provider : resolveProviders(ctx)) { + results.addAll(provider.provide(ctx, Math.max(0, limit - results.size()))); + removeExactPartialMatch(results, ctx.getPartialText()); + if (results.size() >= limit) break; + } + if (results.size() > limit) { + return new ArrayList<>(results.subList(0, limit)); + } + return results; + } + + private static void removeExactPartialMatch(List results, String partialText) { + if (partialText == null || partialText.isEmpty()) { + return; + } + String partial = partialText.toLowerCase(); + for (int i = results.size() - 1; i >= 0; i--) { + String replacement = results.get(i) + .replacementText(); + if (replacement != null && replacement.toLowerCase() + .equals(partial)) { + results.remove(i); + } + } + } + + private static List resolveProviders(AutocompleteContext ctx) { + Set matched = new LinkedHashSet<>(); + if (ctx instanceof MdxValueContext) { + MdxValueContext mdx = (MdxValueContext) ctx; + addValueProviders(matched, mdx.getTagName(), mdx.getAttrName()); + return preserveRegistrationOrder(matched); + } + if (ctx instanceof MdxAttrNameContext) { + MdxAttrNameContext mdx = (MdxAttrNameContext) ctx; + addProviders(matched, AutocompleteKey.MatchType.ATTR_NAME, mdx.getTagName(), null); + addProviders(matched, AutocompleteKey.MatchType.ATTR_NAME, "*", null); + return preserveRegistrationOrder(matched); + } + if (ctx instanceof TagStartContext) { + TagStartContext tagCtx = (TagStartContext) ctx; + addProviders(matched, AutocompleteKey.MatchType.TAG_NAME, null, null); + String parent = tagCtx.getParentTagName(); + if (parent != null) { + addProviders(matched, AutocompleteKey.MatchType.TAG_NAME, parent, null); + } + return preserveRegistrationOrder(matched); + } + if (ctx instanceof FenceLanguageContext) { + addProviders(matched, AutocompleteKey.MatchType.FENCE_LANGUAGE, null, null); + return preserveRegistrationOrder(matched); + } + if (ctx instanceof FrontmatterContext) { + FrontmatterContext fmc = (FrontmatterContext) ctx; + String attr = fmc.isValue() ? fmc.getKey() : "fm_key"; + addValueProviders(matched, "*", attr); + return preserveRegistrationOrder(matched); + } + return providers; + } + + private static List preserveRegistrationOrder(Set matched) { + List ordered = new ArrayList<>(); + for (AutocompleteProvider provider : providers) { + if (matched.contains(provider)) { + ordered.add(provider); + } + } + return ordered; + } + + private static void addValueProviders(Set matched, String tagName, String attrName) { + addProviders(matched, AutocompleteKey.MatchType.ATTR_VALUE, tagName, attrName); + addProviders(matched, AutocompleteKey.MatchType.ATTR_VALUE, tagName, "*"); + addProviders(matched, AutocompleteKey.MatchType.ATTR_VALUE, "*", attrName); + addProviders(matched, AutocompleteKey.MatchType.ATTR_VALUE, "*", "*"); + } + + private static void addProviders(Set matched, AutocompleteKey.MatchType type, String tagName, + String attrName) { + List indexed = providersByKey.get(toLookupKey(type, tagName, attrName)); + if (indexed != null) { + matched.addAll(indexed); + } + } + + private static String toLookupKey(AutocompleteKey.MatchType type, String tagName, String attrName) { + return type.name() + '\u0001' + + (tagName != null ? tagName : "") + + '\u0001' + + (attrName != null ? attrName : ""); + } + + public static void clear() { + providers.clear(); + providersByKey.clear(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/BlockIdProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/BlockIdProvider.java new file mode 100644 index 00000000..ae601db7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/BlockIdProvider.java @@ -0,0 +1,59 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import net.minecraft.block.Block; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests Block registry names for block-related tag "id" attributes. */ +public class BlockIdProvider implements AutocompleteProvider { + + private static final Set KEYS = buildKeys("BlockImage", "PlaceBlock", "RemoveBlocks", "Block"); + + private static Set buildKeys(String... tagNames) { + Set keys = new HashSet<>(); + for (String tag : tagNames) { + keys.add(AutocompleteKey.forValue(tag, "id")); + } + // Also match ReplaceBlock's "from" and "to" attributes + keys.add(AutocompleteKey.forValue("ReplaceBlock", "from")); + keys.add(AutocompleteKey.forValue("ReplaceBlock", "to")); + keys.add(AutocompleteKey.forValue("BlockAnnotationTemplate", "id")); + return Collections.unmodifiableSet(keys); + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (Object obj : Block.blockRegistry.getKeys()) { + if (results.size() >= limit) break; + if (obj instanceof String key) { + if (partial.isEmpty() || key.toLowerCase() + .contains(partial)) { + Block block = (Block) Block.blockRegistry.getObject(key); + ItemStack stack = block != null ? new ItemStack(Item.getItemFromBlock(block)) : null; + if (stack != null && stack.getItem() != null) { + results.add(new RegistryCandidate(key, stack)); + } else { + results.add(new TextCandidate(key)); + } + } + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/BooleanValueProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/BooleanValueProvider.java new file mode 100644 index 00000000..4a76aa64 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/BooleanValueProvider.java @@ -0,0 +1,44 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AttrType; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AttributeSpec; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TagAttributeRegistry; + +public class BooleanValueProvider implements AutocompleteProvider { + + private static final String[] VALUES = { "true", "false" }; + + @Override + public Set getSupportedKeys() { + Set keys = new HashSet<>(); + for (String tag : TagAttributeRegistry.getRegisteredTags()) { + for (AttributeSpec spec : TagAttributeRegistry.get(tag)) { + if (spec.getType() == AttrType.BOOLEAN) { + keys.add(AutocompleteKey.forValue(tag, spec.getName())); + } + } + } + return Collections.unmodifiableSet(keys); + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText(); + String lower = partial != null ? partial.toLowerCase() : ""; + List results = new ArrayList<>(); + for (String value : VALUES) { + if (results.size() >= limit) break; + if (lower.isEmpty() || value.startsWith(lower)) { + results.add(new TextCandidate(value)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ColorCandidate.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ColorCandidate.java new file mode 100644 index 00000000..86d5127c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ColorCandidate.java @@ -0,0 +1,48 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.gui.Gui; + +public class ColorCandidate implements AutocompleteCandidate { + + private final String name; + private final int color; + private static final int SWATCH_SIZE = 12; + private static final int TEXT_X = SWATCH_SIZE + 6; + private static final int TEXT_COLOR = 0xFFF0F0F0; + + public ColorCandidate(String name, int color) { + this.name = name; + this.color = color; + } + + @Override + public String displayText() { + return name; + } + + @Override + public String replacementText() { + return name; + } + + @Override + public int renderHeight() { + return 16; + } + + @Override + public int renderWidth(FontRenderer fontRenderer) { + return SWATCH_SIZE + 6 + fontRenderer.getStringWidth(name) + 6; + } + + @Override + public void render(FontRenderer fontRenderer, int x, int y, int width, boolean hovered) { + // Draw color swatch + int swatchY = y + (renderHeight() - SWATCH_SIZE) / 2; + Gui.drawRect(x, swatchY, x + SWATCH_SIZE, swatchY + SWATCH_SIZE, 0xFF000000 | color); + Gui.drawRect(x - 1, swatchY - 1, x + SWATCH_SIZE + 1, swatchY + SWATCH_SIZE + 1, 0xFF4D5661); + // Draw name + fontRenderer.drawString(name, x + TEXT_X, y + 3, TEXT_COLOR); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ColorProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ColorProvider.java new file mode 100644 index 00000000..58d9e3d7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ColorProvider.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests SymbolicColor names for <Color id> attributes. */ +public class ColorProvider implements AutocompleteProvider { + + private static final String[] SYMBOLIC_NAMES = { "LINK", "BODY_TEXT", "ERROR_TEXT", "CRAFTING_RECIPE_TYPE", + "THEMATIC_BREAK", "HEADER1_SEPARATOR", "HEADER2_SEPARATOR", "NAVBAR_BG_TOP", "NAVBAR_BG_BOTTOM", + "NAVBAR_ROW_HOVER", "NAVBAR_EXPAND_ARROW", "TABLE_BORDER", "ICON_BUTTON_NORMAL", "ICON_BUTTON_DISABLED", + "ICON_BUTTON_HOVER", "IN_WORLD_BLOCK_HIGHLIGHT", "SCENE_BACKGROUND", "GUIDE_SCREEN_BACKGROUND", + "BLOCKQUOTE_BACKGROUND", "BLACK", "DARK_BLUE", "DARK_GREEN", "DARK_AQUA", "DARK_RED", "DARK_PURPLE", "GOLD", + "GRAY", "DARK_GRAY", "BLUE", "GREEN", "AQUA", "RED", "LIGHT_PURPLE", "YELLOW", "WHITE" }; + + @Override + public Set getSupportedKeys() { + return Collections.singleton(AutocompleteKey.forValue("Color", "id")); + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String name : SYMBOLIC_NAMES) { + if (results.size() >= limit) break; + if (partial.isEmpty() || name.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(name)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/CommandProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/CommandProvider.java new file mode 100644 index 00000000..19065043 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/CommandProvider.java @@ -0,0 +1,44 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import net.minecraft.command.ICommand; +import net.minecraftforge.client.ClientCommandHandler; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests registered client-side commands for <CommandLink command>. */ +public class CommandProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections + .singleton(AutocompleteKey.forValue("CommandLink", "command")); + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + @SuppressWarnings("unchecked") + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (Object cmdObj : ClientCommandHandler.instance.getCommands() + .values()) { + if (results.size() >= limit) break; + if (cmdObj instanceof ICommand) { + ICommand cmd = (ICommand) cmdObj; + String name = cmd.getCommandName(); + if (partial.isEmpty() || name.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate("/" + name)); + } + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/DomainProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/DomainProvider.java new file mode 100644 index 00000000..9a8ad488 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/DomainProvider.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests domain interval templates for domain attributes. */ +public class DomainProvider implements AutocompleteProvider { + + private static final Set KEYS = new HashSet<>( + Arrays.asList(AutocompleteKey.forValue("Function", "domain"), AutocompleteKey.forValue("Plot", "domain"))); + + private static final String[] DOMAINS = { "-inf..inf", "-10..10", "-pi..pi", "0..2*pi", "..0", "0..", "-5..5", + "-1..1" }; + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String d : DOMAINS) { + if (results.size() >= limit) break; + if (partial.isEmpty() || d.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(d)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/EntityNameProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/EntityNameProvider.java new file mode 100644 index 00000000..ee177b98 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/EntityNameProvider.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import net.minecraft.entity.EntityList; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests entity registry names for <Entity id> attributes. */ +public class EntityNameProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.singleton(AutocompleteKey.forValue("Entity", "id")); + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + @SuppressWarnings("unchecked") + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (Object obj : EntityList.stringToClassMapping.keySet()) { + if (results.size() >= limit) break; + if (obj instanceof String key) { + if (partial.isEmpty() || key.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(key)); + } + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/EnumValueProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/EnumValueProvider.java new file mode 100644 index 00000000..275ccc0d --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/EnumValueProvider.java @@ -0,0 +1,60 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.compiler.tags.SerializedEnum; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AttrType; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AttributeSpec; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TagAttributeRegistry; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxValueContext; + +public class EnumValueProvider implements AutocompleteProvider { + + @Override + public Set getSupportedKeys() { + Set keys = new HashSet<>(); + for (String tag : TagAttributeRegistry.getRegisteredTags()) { + for (AttributeSpec spec : TagAttributeRegistry.get(tag)) { + if (spec.getType() == AttrType.ENUM) { + keys.add(AutocompleteKey.forValue(tag, spec.getName())); + } + } + } + return Collections.unmodifiableSet(keys); + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!(ctx instanceof MdxValueContext)) return Collections.emptyList(); + MdxValueContext mdx = (MdxValueContext) ctx; + + for (AttributeSpec spec : TagAttributeRegistry.get(mdx.getTagName())) { + if (!spec.getName() + .equals(mdx.getAttrName())) continue; + if (spec.getType() != AttrType.ENUM || spec.getEnumClass() == null) continue; + + String partial = mdx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (Enum e : spec.getEnumClass() + .getEnumConstants()) { + if (results.size() >= limit) break; + String name = e.name(); + if (e instanceof SerializedEnum) { + name = ((SerializedEnum) e).getSerializedName(); + } + if (partial.isEmpty() || name.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(name)); + } + } + return results; + } + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ExpressionProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ExpressionProvider.java new file mode 100644 index 00000000..221e2826 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ExpressionProvider.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests common math expressions for <Function expr> and <Plot expr>. */ +public class ExpressionProvider implements AutocompleteProvider { + + private static final Set KEYS = new HashSet<>( + Arrays.asList(AutocompleteKey.forValue("Function", "expr"), AutocompleteKey.forValue("Plot", "expr"))); + + private static final String[] EXPRESSIONS = { "sin(x)", "cos(x)", "tan(x)", "x^2", "x^3", "sqrt(x)", "abs(x)", + "log(x)", "ln(x)", "exp(x)", "1/x", "sin(x)*cos(x)", "x*sin(x)", "floor(x)", "ceil(x)" }; + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String expr : EXPRESSIONS) { + if (results.size() >= limit) break; + if (partial.isEmpty() || expr.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(expr)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FencedBlockLanguageProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FencedBlockLanguageProvider.java new file mode 100644 index 00000000..cd69afb8 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FencedBlockLanguageProvider.java @@ -0,0 +1,36 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public class FencedBlockLanguageProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.singleton(AutocompleteKey.forFenceLanguage()); + private static final String[] LANGUAGES = { "text", "java", "kotlin", "scala", "groovy", "lua", "json", "yaml", + "xml", "properties", "bash", "sh", "powershell", "markdown", "csv", "mermaid", "tree", "filetree", + "funcgraph" }; + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText(); + String lower = partial != null ? partial.toLowerCase() : ""; + List results = new ArrayList<>(); + for (String lang : LANGUAGES) { + if (results.size() >= limit) break; + if (lower.isEmpty() || lang.toLowerCase() + .startsWith(lower)) { + results.add(new TextCandidate(lang)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FormatPatternProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FormatPatternProvider.java new file mode 100644 index 00000000..991a13e4 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FormatPatternProvider.java @@ -0,0 +1,37 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests format patterns for <ItemImage format>. */ +public class FormatPatternProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections + .singleton(AutocompleteKey.forValue("ItemImage", "format")); + + private static final String[] PATTERNS = { "%s", "%s items", "**%s**", "*%s*", "~~%s~~" }; + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String p : PATTERNS) { + if (results.size() >= limit) break; + if (partial.isEmpty() || p.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(p)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FrontmatterKeyProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FrontmatterKeyProvider.java new file mode 100644 index 00000000..07622366 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FrontmatterKeyProvider.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.FrontmatterContext; + +/** Suggests valid YAML frontmatter top-level keys. */ +public class FrontmatterKeyProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.singleton(AutocompleteKey.forValue("*", "fm_key")); + + private static final String[] KEYS_LIST = { "navigation", "item_ids", "ore_ids", "quest_ids", "categories", + "authors", "author", "date", "updated", "zoom" }; + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!(ctx instanceof FrontmatterContext)) return Collections.emptyList(); + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String key : KEYS_LIST) { + if (results.size() >= limit) break; + if (partial.isEmpty() || key.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(key)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FrontmatterValueProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FrontmatterValueProvider.java new file mode 100644 index 00000000..35d81f92 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/FrontmatterValueProvider.java @@ -0,0 +1,58 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.FrontmatterContext; + +/** Dispatches frontmatter value completion based on the current key. */ +public class FrontmatterValueProvider implements AutocompleteProvider { + + private static final Map HINTS = new LinkedHashMap<>(); + static { + HINTS.put( + "navigation", + new String[] { "\n title:", "\n parent:", "\n position:", "\n icon:", "\n icon_texture:" }); + // TODO: integrate with BetterQuesting for dynamic quest UUID lookup + HINTS.put("quest_ids", new String[] { "\n - 00000000-0000-0000-0000-000000000000" }); + } + + private static final Set KEYS = buildKeys(); + + private static Set buildKeys() { + Set keys = new HashSet<>(); + for (String k : HINTS.keySet()) { + keys.add(AutocompleteKey.forValue("*", k)); + } + return Collections.unmodifiableSet(keys); + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!(ctx instanceof FrontmatterContext)) return Collections.emptyList(); + FrontmatterContext fmc = (FrontmatterContext) ctx; + String[] suggestions = HINTS.getOrDefault(fmc.getKey(), new String[0]); + String partial = fmc.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String s : suggestions) { + if (results.size() >= limit) break; + if (partial.isEmpty() || s.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(s)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ImagePathProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ImagePathProvider.java new file mode 100644 index 00000000..526254b0 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ImagePathProvider.java @@ -0,0 +1,156 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.IResourcePack; +import net.minecraft.client.resources.ResourcePackRepository; +import net.minecraft.util.ResourceLocation; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.Guide; +import com.hfstudio.guidenh.guide.compiler.ParsedGuidePage; +import com.hfstudio.guidenh.guide.internal.datadriven.DataDrivenGuideLoader; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.mixins.early.fml.AccessorFMLClientHandler; + +import cpw.mods.fml.client.FMLClientHandler; + +/** + * Suggests file paths from the guide assets directory for src attributes. + * Call {@link #refreshFromGuide(Guide)} once to initialize the scanned directories. + */ +public class ImagePathProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList( + AutocompleteKey.forValue("FloatingImage", "src"), + AutocompleteKey.forValue("ImportStructure", "src"), + AutocompleteKey.forValue("ImportPonder", "src"), + AutocompleteKey.forValue("Mermaid", "src"), + AutocompleteKey.forValue("CsvTable", "src"), + AutocompleteKey.forValue("*", "icon_texture"), + AutocompleteKey.forValue("image", "url")))); + + private static final String[] EXTENSIONS = { ".png", ".jpg", ".jpeg", ".gif", ".snbt", ".nbt", ".csv", ".json", + ".mmd", ".md" }; + + @Nullable + private static volatile List baseDirs; + @Nullable + private static volatile List candidatePaths; + private static volatile boolean scanned; + + /** + * Scans the guide's resource pack directories for asset folders matching + * page paths. This method performs the scan only once; subsequent calls + * are no-ops once the scan has completed. + */ + public static void refreshFromGuide(@Nullable Guide guide) { + if (scanned || guide == null) return; + scanned = true; + + List dirs = new ArrayList<>(); + + // Collect all active resource packs (same pattern as DataDrivenGuideLoader) + List packs = new ArrayList<>(); + var fmlAccessor = (AccessorFMLClientHandler) FMLClientHandler.instance(); + List basePacks = fmlAccessor.guidenh$getResourcePackList(); + if (basePacks != null) packs.addAll(basePacks); + Minecraft mc = Minecraft.getMinecraft(); + if (mc.getResourcePackRepository() != null) { + for (ResourcePackRepository.Entry entry : mc.getResourcePackRepository() + .getRepositoryEntries()) { + IResourcePack rp = entry.getResourcePack(); + if (rp != null) packs.add(rp); + } + } + + for (ParsedGuidePage page : guide.getPages()) { + ResourceLocation id = page.getId(); + String ns = id.getResourceDomain(); + String path = id.getResourcePath(); + + // Extract directory portion: "path/to/page.md" -> "path/to" + int slashIdx = path.lastIndexOf('/'); + String dirPath = slashIdx > 0 ? path.substring(0, slashIdx) : ""; + + for (IResourcePack rp : packs) { + File packFile = DataDrivenGuideLoader.getResourcePackFile(rp); + if (packFile == null) continue; + File assetsDir = new File(packFile, "assets/" + ns + "/" + dirPath); + if (assetsDir.isDirectory() && !dirs.contains(assetsDir)) { + dirs.add(assetsDir); + } + } + } + + baseDirs = dirs.isEmpty() ? null : Collections.unmodifiableList(dirs); + candidatePaths = buildCandidatePaths(dirs); + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + List paths = candidatePaths; + if (paths == null || paths.isEmpty()) return Collections.emptyList(); + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String path : paths) { + if (results.size() >= limit) break; + if (partial.isEmpty() || path.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(path)); + } + } + return results; + } + + private static List buildCandidatePaths(List dirs) { + if (dirs.isEmpty()) { + return Collections.emptyList(); + } + List paths = new ArrayList<>(); + Set seen = new HashSet<>(); + for (File dir : dirs) { + scanDir(dir, "", paths, seen); + } + return Collections.unmodifiableList(paths); + } + + private static void scanDir(File dir, String prefix, List paths, Set seen) { + File[] files = dir.listFiles(); + if (files == null) return; + for (File f : files) { + String name = f.getName(); + if (name.startsWith(".")) continue; + String relPath = prefix.isEmpty() ? name : prefix + "/" + name; + if (f.isDirectory()) { + scanDir(f, relPath, paths, seen); + } else if (matchesExtension(name) && seen.add(relPath)) { + paths.add(relPath); + } + } + } + + private static boolean matchesExtension(String name) { + String lower = name.toLowerCase(); + for (String ext : EXTENSIONS) { + if (lower.endsWith(ext)) return true; + } + return false; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ItemCandidate.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ItemCandidate.java new file mode 100644 index 00000000..a274acbd --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ItemCandidate.java @@ -0,0 +1,62 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.renderer.RenderHelper; +import net.minecraft.client.renderer.entity.RenderItem; +import net.minecraft.item.ItemStack; + +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL12; + +public class ItemCandidate implements AutocompleteCandidate { + + private final String id; + private final ItemStack stack; + private static final int ICON_SIZE = 16; + private static final int TEXT_X = ICON_SIZE + 2; + private static final int TEXT_COLOR = 0xFFF0F0F0; + private static final RenderItem renderItem = new RenderItem(); + + public ItemCandidate(String id, ItemStack stack) { + this.id = id; + this.stack = stack; + } + + @Override + public String displayText() { + return id; + } + + @Override + public String replacementText() { + return id; + } + + @Override + public int renderHeight() { + return 16; + } + + @Override + public void render(FontRenderer fontRenderer, int x, int y, int width, boolean hovered) { + GL11.glPushMatrix(); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + RenderHelper.enableGUIStandardItemLighting(); + GL11.glEnable(GL12.GL_RESCALE_NORMAL); + renderItem.zLevel = 0; + // renderItemAndEffectIntoGUI draws in a 16x16 box; center it vertically in our 16px row + renderItem.renderItemAndEffectIntoGUI( + Minecraft.getMinecraft().fontRenderer, + Minecraft.getMinecraft() + .getTextureManager(), + stack, + x, + y - 1); + RenderHelper.disableStandardItemLighting(); + GL11.glDisable(GL11.GL_BLEND); + GL11.glPopMatrix(); + fontRenderer.drawString(id, x + TEXT_X, y + 4, TEXT_COLOR); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ItemIdProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ItemIdProvider.java new file mode 100644 index 00000000..b97616b1 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/ItemIdProvider.java @@ -0,0 +1,152 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public class ItemIdProvider implements AutocompleteProvider { + + // Tags whose "id" attribute refers to a Minecraft item registry key + private static Set KEYS = buildKeys( + // item id attributes + "ItemImage", + "ItemLink", + "Recipe", + "RecipeFor", + "RecipesFor", + // recipe filter attributes + "Recipe", + "RecipeFor", + "RecipesFor"); + + // Also add these extra keys after buildKeys: + static { + Set allKeys = new HashSet<>(KEYS); + allKeys.add(AutocompleteKey.forValue("Recipe", "input")); + allKeys.add(AutocompleteKey.forValue("Recipe", "output")); + allKeys.add(AutocompleteKey.forValue("RecipeFor", "input")); + allKeys.add(AutocompleteKey.forValue("RecipeFor", "output")); + allKeys.add(AutocompleteKey.forValue("RecipesFor", "input")); + allKeys.add(AutocompleteKey.forValue("RecipesFor", "output")); + allKeys.add(AutocompleteKey.forValue("ItemIcon", "id")); + // Frontmatter wildcards + allKeys.add(AutocompleteKey.forValue("*", "item_ids")); + allKeys.add(AutocompleteKey.forValue("*", "icon")); + KEYS = Collections.unmodifiableSet(allKeys); + } + + private List cachedSortedKeys = Collections.emptyList(); + private int cachedRegistryKeyCount = -1; + + private static Set buildKeys(String... tagNames) { + Set keys = new HashSet<>(); + for (String tag : tagNames) { + keys.add(AutocompleteKey.forValue(tag, "id")); + } + return Collections.unmodifiableSet(keys); + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText(); + List keys = getSortedRegistryKeys(); + List results = new ArrayList<>(); + for (String candidate : collectCandidateTexts(keys, partial, limit, false)) { + if (candidate.endsWith(":")) { + results.add(new TextCandidate(candidate)); + continue; + } + Item item = (Item) Item.itemRegistry.getObject(candidate); + if (item != null) { + results.add(new ItemCandidate(candidate, new ItemStack(item))); + } + } + return results; + } + + private List getSortedRegistryKeys() { + Set keysView = Item.itemRegistry.getKeys(); + if (cachedRegistryKeyCount == keysView.size()) { + return cachedSortedKeys; + } + List keys = new ArrayList<>(); + for (Object obj : keysView) { + if (obj instanceof String key) { + keys.add(key); + } + } + cachedSortedKeys = sortedKeys(keys); + cachedRegistryKeyCount = keysView.size(); + return cachedSortedKeys; + } + + public static List collectCandidateTexts(List keys, String partial, int limit) { + return collectCandidateTexts(keys, partial, limit, true); + } + + private static List collectCandidateTexts(List keys, String partial, int limit, boolean sortKeys) { + int safeLimit = Math.max(0, limit); + if (safeLimit <= 0) { + return Collections.emptyList(); + } + String lower = partial != null ? partial.toLowerCase(Locale.ROOT) : ""; + List candidates = new ArrayList<>(Math.min(safeLimit, keys.size())); + List sortedKeys = sortKeys ? sortedKeys(keys) : keys; + if (lower.indexOf(':') < 0) { + addNamespaceCandidateTexts(candidates, sortedKeys, lower, safeLimit); + } + addItemCandidateTexts(candidates, sortedKeys, lower, safeLimit); + return candidates; + } + + private static void addNamespaceCandidateTexts(List results, List sortedKeys, String lower, + int limit) { + Set namespaces = new LinkedHashSet<>(); + for (String key : sortedKeys) { + int separator = key.indexOf(':'); + if (separator <= 0) continue; + String namespace = key.substring(0, separator); + String namespaceLower = namespace.toLowerCase(Locale.ROOT); + if (lower.isEmpty() || namespaceLower.startsWith(lower)) { + namespaces.add(namespace); + } + } + for (String namespace : namespaces) { + if (results.size() >= limit) break; + results.add(namespace + ":"); + } + } + + private static void addItemCandidateTexts(List results, List sortedKeys, String lower, int limit) { + for (String key : sortedKeys) { + if (results.size() >= limit) break; + if (lower.isEmpty() || key.toLowerCase(Locale.ROOT) + .contains(lower)) { + results.add(key); + } + } + } + + private static List sortedKeys(List keys) { + List sorted = new ArrayList<>(keys); + sorted.sort( + Comparator.comparing((String key) -> key.toLowerCase(Locale.ROOT)) + .thenComparing(Comparator.naturalOrder())); + return sorted; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/KeyBindProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/KeyBindProvider.java new file mode 100644 index 00000000..a14270fa --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/KeyBindProvider.java @@ -0,0 +1,38 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.settings.KeyBinding; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests key binding descriptions for <KeyBind id> attributes. */ +public class KeyBindProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.singleton(AutocompleteKey.forValue("KeyBind", "id")); + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (KeyBinding kb : Minecraft.getMinecraft().gameSettings.keyBindings) { + if (results.size() >= limit) break; + String desc = kb.getKeyDescription(); + if (partial.isEmpty() || desc.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(desc)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/NumericValueProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/NumericValueProvider.java new file mode 100644 index 00000000..0ea93dab --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/NumericValueProvider.java @@ -0,0 +1,57 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.MdxValueContext; + +/** Suggests common numeric values for INT/FLOAT attributes. */ +public class NumericValueProvider implements AutocompleteProvider { + + private static final Map SUGGESTIONS = new LinkedHashMap<>(); + static { + SUGGESTIONS.put("width", new String[] { "100", "200", "256", "300", "400", "512" }); + SUGGESTIONS.put("height", new String[] { "100", "200", "256", "300", "400" }); + SUGGESTIONS.put("scale", new String[] { "0.5", "1.0", "1.5", "2.0", "3.0" }); + SUGGESTIONS.put("zoom", new String[] { "0.5", "1.0", "1.5", "2.0", "3.0" }); + SUGGESTIONS.put("gap", new String[] { "0", "5", "10", "15", "20" }); + SUGGESTIONS.put("x", new String[] { "0" }); + SUGGESTIONS.put("y", new String[] { "0" }); + SUGGESTIONS.put("z", new String[] { "0" }); + SUGGESTIONS.put("startAngle", new String[] { "-90", "0", "90", "180" }); + SUGGESTIONS.put("barWidthRatio", new String[] { "0.5", "0.7", "0.9" }); + } + + @Override + public Set getSupportedKeys() { + Set keys = new HashSet<>(); + for (String attr : SUGGESTIONS.keySet()) { + keys.add(AutocompleteKey.forValue("*", attr)); + } + return Collections.unmodifiableSet(keys); + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!(ctx instanceof MdxValueContext)) return Collections.emptyList(); + String attrName = ((MdxValueContext) ctx).getAttrName(); + String[] vals = SUGGESTIONS.get(attrName); + if (vals == null) return Collections.emptyList(); + + String partial = ctx.getPartialText(); + List results = new ArrayList<>(); + for (String v : vals) { + if (partial.isEmpty() || v.startsWith(partial)) { + results.add(new TextCandidate(v)); + } + if (results.size() >= limit) break; + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/OreDictProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/OreDictProvider.java new file mode 100644 index 00000000..d722bf22 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/OreDictProvider.java @@ -0,0 +1,39 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import net.minecraftforge.oredict.OreDictionary; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests OreDictionary names for "ore" attributes. */ +public class OreDictProvider implements AutocompleteProvider { + + private static final Set KEYS = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList(AutocompleteKey.forValue("*", "ore"), AutocompleteKey.forValue("*", "ore_ids")))); + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String name : OreDictionary.getOreNames()) { + if (results.size() >= limit) break; + if (partial.isEmpty() || name.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(name)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/PageReferenceProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/PageReferenceProvider.java new file mode 100644 index 00000000..eb8e0ced --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/PageReferenceProvider.java @@ -0,0 +1,56 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Suggests guide page paths. Call {@link #setPages} before use. */ +public class PageReferenceProvider implements AutocompleteProvider { + + private static final Set KEYS = buildKeys(); + @Nullable + private static volatile List pagePaths; + + private static Set buildKeys() { + Set keys = new HashSet<>(); + keys.add(AutocompleteKey.forValue("a", "href")); + keys.add(AutocompleteKey.forValue("SubPages", "id")); + keys.add(AutocompleteKey.forValue("ItemLink", "linksTo")); + keys.add(AutocompleteKey.forValue("*", "parent")); + keys.add(AutocompleteKey.forValue("link", "url")); + return Collections.unmodifiableSet(keys); + } + + /** Set the available page paths from the guide's page collection. */ + public static void setPages(@Nullable Collection paths) { + pagePaths = paths != null ? Collections.unmodifiableList(new ArrayList<>(paths)) : null; + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (pagePaths == null) return Collections.emptyList(); + String partial = ctx.getPartialText() + .toLowerCase(); + List results = new ArrayList<>(); + for (String path : pagePaths) { + if (results.size() >= limit) break; + if (partial.isEmpty() || path.toLowerCase() + .contains(partial)) { + results.add(new TextCandidate(path)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/RegistryCandidate.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/RegistryCandidate.java new file mode 100644 index 00000000..e998b600 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/RegistryCandidate.java @@ -0,0 +1,92 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.renderer.RenderHelper; +import net.minecraft.client.renderer.entity.RenderItem; +import net.minecraft.item.ItemStack; + +import org.jetbrains.annotations.Nullable; +import org.lwjgl.opengl.GL11; +import org.lwjgl.opengl.GL12; + +/** Candidate displaying a registry key with optional item icon and subtitle. */ +public class RegistryCandidate implements AutocompleteCandidate { + + private final String key; + @Nullable + private final String subtitle; + @Nullable + private final ItemStack icon; + private static final int ICON_SIZE = 16; + private static final int TEXT_X = ICON_SIZE + 2; + private static final int TEXT_COLOR = 0xFFF0F0F0; + private static final int SUBTITLE_COLOR = 0xFFA0A0A0; + private static final RenderItem renderItem = new RenderItem(); + + public RegistryCandidate(String key) { + this(key, null, null); + } + + public RegistryCandidate(String key, @Nullable String subtitle) { + this(key, subtitle, null); + } + + public RegistryCandidate(String key, @Nullable ItemStack icon) { + this(key, null, icon); + } + + public RegistryCandidate(String key, @Nullable String subtitle, @Nullable ItemStack icon) { + this.key = key; + this.subtitle = subtitle; + this.icon = icon; + } + + @Override + public String displayText() { + return key; + } + + @Override + public String replacementText() { + return key; + } + + @Override + public int renderHeight() { + return subtitle != null ? 28 : 16; + } + + @Override + public int renderWidth(FontRenderer fr) { + return icon != null ? ICON_SIZE + 4 + fr.getStringWidth(key) : 0; + } + + @Override + public void render(FontRenderer fontRenderer, int x, int y, int width, boolean hovered) { + int textX = x; + if (icon != null) { + GL11.glPushMatrix(); + GL11.glEnable(GL11.GL_BLEND); + GL11.glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + RenderHelper.enableGUIStandardItemLighting(); + GL11.glEnable(GL12.GL_RESCALE_NORMAL); + renderItem.zLevel = 0; + renderItem.renderItemAndEffectIntoGUI( + Minecraft.getMinecraft().fontRenderer, + Minecraft.getMinecraft() + .getTextureManager(), + icon, + x, + y - 1); + RenderHelper.disableStandardItemLighting(); + GL11.glDisable(GL11.GL_BLEND); + GL11.glPopMatrix(); + textX = x + TEXT_X; + } + fontRenderer.drawString(key, textX, y + 3, TEXT_COLOR); + if (subtitle != null) { + fontRenderer.drawString(subtitle, textX + 4, y + 14, SUBTITLE_COLOR); + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/TagNameProvider.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/TagNameProvider.java new file mode 100644 index 00000000..a5a0ed20 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/TagNameProvider.java @@ -0,0 +1,72 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver.TagStartContext; + +public class TagNameProvider implements AutocompleteProvider { + + private static final Set KEYS = buildKeys(); + private static volatile boolean enabled = true; + + private static final String[] TAG_NAMES = { "a", "br", "Tooltip", "ItemImage", "ItemLink", "BlockImage", "Color", + "CommandLink", "kbd", "KeyBind", "Latex", "mark", "PlayerName", "sub", "sup", "FloatingImage", "Row", "Column", + "div", "details", "CategoryIndex", "CsvTable", "FileTree", "FootnoteList", "ItemGrid", "Mermaid", "Recipe", + "RecipeFor", "RecipesFor", "Structure", "SubPages", "ColumnChart", "BarChart", "LineChart", "PieChart", + "ScatterChart", "FunctionGraph", "Function", "GameScene", "Scene", "Block", "Entity", "PlaceBlock", + "ReplaceBlock", "RemoveBlocks", "ImportStructure", "ImportStructureLib", "ImportPonder", "IsometricCamera", + "Tier", "Channel", "Facing", "Rotation", "Flip", "Orientation", "GregTechActiveController", + "GregTechPlaceHatches", "BlockAnnotation", "BoxAnnotation", "LineAnnotation", "DiamondAnnotation", + "TextAnnotation", "BlockAnnotationTemplate" }; + + private static final String[] GAME_SCENE_TAG_NAMES = { "ImportStructure", "ImportStructureLib", "RemoveBlocks", + "BlockAnnotationTemplate", "BlockAnnotation", "BoxAnnotation", "LineAnnotation", "DiamondAnnotation", + "TextAnnotation", "Block", "Entity", "PlaceBlock", "ReplaceBlock", "IsometricCamera", "ImportPonder", "Tier", + "Channel", "Facing", "Rotation", "Flip", "Orientation", "GregTechActiveController", "GtActiveController", + "GregTechPlaceHatches", "GtPlaceHatches" }; + + private static Set buildKeys() { + Set keys = new HashSet<>(); + keys.add(AutocompleteKey.forTag()); + keys.add(AutocompleteKey.forTag("GameScene")); + keys.add(AutocompleteKey.forTag("Scene")); + return Collections.unmodifiableSet(keys); + } + + public static void setEnabled(boolean value) { + enabled = value; + } + + @Override + public Set getSupportedKeys() { + return KEYS; + } + + @Override + public List provide(AutocompleteContext ctx, int limit) { + if (!enabled) return Collections.emptyList(); + String partial = ctx.getPartialText(); + String lower = partial != null ? partial.toLowerCase() : ""; + String[] names = TAG_NAMES; + if (ctx instanceof TagStartContext) { + String parent = ((TagStartContext) ctx).getParentTagName(); + if ("GameScene".equals(parent) || "Scene".equals(parent)) { + names = GAME_SCENE_TAG_NAMES; + } + } + List results = new ArrayList<>(); + for (String name : names) { + if (results.size() >= limit) break; + if (lower.isEmpty() || name.toLowerCase() + .startsWith(lower)) { + results.add(new TextCandidate(name)); + } + } + return results; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/TextCandidate.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/TextCandidate.java new file mode 100644 index 00000000..fd4cdb10 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/provider/TextCandidate.java @@ -0,0 +1,28 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider; + +import net.minecraft.client.gui.FontRenderer; + +public class TextCandidate implements AutocompleteCandidate { + + private final String text; + private static final int TEXT_COLOR = 0xFFF0F0F0; + + public TextCandidate(String text) { + this.text = text; + } + + @Override + public String displayText() { + return text; + } + + @Override + public String replacementText() { + return text; + } + + @Override + public void render(FontRenderer fontRenderer, int x, int y, int width, boolean hovered) { + fontRenderer.drawString(text, x, y + 2, TEXT_COLOR); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/CompositeResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/CompositeResolver.java new file mode 100644 index 00000000..dc7f4855 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/CompositeResolver.java @@ -0,0 +1,29 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import java.util.Arrays; +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxContextResolver; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TextSyntaxContext; + +/** Chains multiple resolvers; first non-null result wins. */ +public class CompositeResolver implements SyntaxContextResolver { + + private final List chain; + + public CompositeResolver(SyntaxContextResolver... resolvers) { + this.chain = Arrays.asList(resolvers); + } + + @Override + @Nullable + public TextSyntaxContext resolve(String text, int cursorIndex) { + for (SyntaxContextResolver r : chain) { + TextSyntaxContext ctx = r.resolve(text, cursorIndex); + if (ctx != null) return ctx; + } + return null; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/FenceLanguageContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/FenceLanguageContext.java new file mode 100644 index 00000000..5c2a1b62 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/FenceLanguageContext.java @@ -0,0 +1,31 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public class FenceLanguageContext implements AutocompleteContext { + + private final int replaceStart; + private final int replaceEnd; + private final String partialText; + + public FenceLanguageContext(int replaceStart, int replaceEnd, String partialText) { + this.replaceStart = replaceStart; + this.replaceEnd = replaceEnd; + this.partialText = partialText; + } + + @Override + public int replaceStart() { + return replaceStart; + } + + @Override + public int replaceEnd() { + return replaceEnd; + } + + @Override + public String getPartialText() { + return partialText; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/FrontmatterContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/FrontmatterContext.java new file mode 100644 index 00000000..b4d1dc22 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/FrontmatterContext.java @@ -0,0 +1,43 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public class FrontmatterContext implements AutocompleteContext { + + private final String key; + private final boolean isValue; + private final int replaceStart; + private final int replaceEnd; + private final String partialText; + + public FrontmatterContext(String key, boolean isValue, int replaceStart, int replaceEnd, String partialText) { + this.key = key; + this.isValue = isValue; + this.replaceStart = replaceStart; + this.replaceEnd = replaceEnd; + this.partialText = partialText; + } + + public String getKey() { + return key; + } + + public boolean isValue() { + return isValue; + } + + @Override + public int replaceStart() { + return replaceStart; + } + + @Override + public int replaceEnd() { + return replaceEnd; + } + + @Override + public String getPartialText() { + return partialText; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxAttrNameContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxAttrNameContext.java new file mode 100644 index 00000000..a92a4457 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxAttrNameContext.java @@ -0,0 +1,37 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public class MdxAttrNameContext implements AutocompleteContext { + + private final String tagName; + private final int replaceStart; + private final int replaceEnd; + private final String partialText; + + public MdxAttrNameContext(String tagName, int replaceStart, int replaceEnd, String partialText) { + this.tagName = tagName; + this.replaceStart = replaceStart; + this.replaceEnd = replaceEnd; + this.partialText = partialText; + } + + public String getTagName() { + return tagName; + } + + @Override + public int replaceStart() { + return replaceStart; + } + + @Override + public int replaceEnd() { + return replaceEnd; + } + + @Override + public String getPartialText() { + return partialText; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java new file mode 100644 index 00000000..fc59cd18 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxSyntaxResolver.java @@ -0,0 +1,646 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import java.util.Collections; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.compiler.GuideMarkdownOptions; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxContextResolver; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxElementType; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxUtils; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TextSyntaxContext; +import com.hfstudio.guidenh.guide.internal.markdown.MdAstToMdxConverter; +import com.hfstudio.guidenh.libs.mdast.MdAst; +import com.hfstudio.guidenh.libs.mdast.MdAstYamlFrontmatter; +import com.hfstudio.guidenh.libs.mdast.MdastOptions; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxAttribute; +import com.hfstudio.guidenh.libs.mdast.mdx.model.MdxJsxElementFields; +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.MdAstCode; +import com.hfstudio.guidenh.libs.mdast.model.MdAstImage; +import com.hfstudio.guidenh.libs.mdast.model.MdAstLink; +import com.hfstudio.guidenh.libs.mdast.model.MdAstParent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstResource; +import com.hfstudio.guidenh.libs.mdast.model.MdAstRoot; +import com.hfstudio.guidenh.libs.unist.UnistNode; +import com.hfstudio.guidenh.libs.unist.UnistPosition; + +public class MdxSyntaxResolver implements SyntaxContextResolver { + + private static final MdastOptions PARSE_OPTIONS = GuideMarkdownOptions.runtime(); + + @Nullable + private String cachedText; + @Nullable + private MdAstRoot cachedRoot; + + @Override + @Nullable + public TextSyntaxContext resolve(String text, int cursorIndex) { + if (text == null || text.isEmpty() || cursorIndex < 0 || cursorIndex > text.length()) return null; + + MdAstRoot root; + if (text.equals(cachedText) && cachedRoot != null) { + root = cachedRoot; + } else { + root = MdAst.fromMarkdown(text, PARSE_OPTIONS); + MdAstToMdxConverter.convert(root, Collections.emptyMap()); + cachedText = text; + cachedRoot = root; + } + + return resolveFromAst(root, text, cursorIndex); + } + + @Nullable + private TextSyntaxContext resolveFromAst(MdAstRoot root, String text, int cursorIndex) { + // 1. YAML frontmatter + MdAstYamlFrontmatter yaml = findEnclosingNode(root, cursorIndex, MdAstYamlFrontmatter.class); + if (yaml != null) { + TextSyntaxContext result = resolveFrontmatter(yaml, text, cursorIndex); + if (result != null && result.shouldAutocomplete()) { + return result; + } + } + + // 2. Code fence language + MdAstCode code = findEnclosingNode(root, cursorIndex, MdAstCode.class); + if (code != null && code.lang != null && !code.lang.isEmpty()) { + TextSyntaxContext result = resolveFenceLanguage(code, text, cursorIndex); + if (result != null) return result; + } + + // 2.5. Markdown link/image URL + MdAstResource res = findEnclosingLink(root, cursorIndex); + if (res != null) { + TextSyntaxContext result = resolveLinkUrl(res, text, cursorIndex); + if (result != null) return result; + } + + // 3. MDX element + MdxJsxElementFields element = findEnclosingMdxElement(root, cursorIndex); + if (element != null) { + TextSyntaxContext result = resolveMdxAttribute(element, text, cursorIndex); + if (result != null && result.getElementType() != SyntaxElementType.WORD) { + return result; + } + } + + // 4. Tag start + TextSyntaxContext tagStart = resolveTagStart(text, cursorIndex, element != null ? element.name() : null); + if (tagStart != null) { + return tagStart; + } + + return resolvePlainTextWord(text, cursorIndex); + } + + // ---- YAML frontmatter ---- + + @Nullable + private TextSyntaxContext resolveFrontmatter(MdAstYamlFrontmatter yaml, String text, int cursorIndex) { + String line = getLineAt(text, cursorIndex); + if (line == null) return resolvePlainTextWord(text, cursorIndex); + + // Empty line: inherit context from preceding indented parent key + if (line.trim() + .isEmpty()) { + return resolveFrontmatterEmptyLine(text, cursorIndex); + } + + // List items inherit context from their parent key, regardless of + // whether the value contains a colon (e.g. "guidenh:guide_icon"). + String trimmed = line.trim(); + if (isYamlListMarker(trimmed)) { + return resolveFrontmatterEmptyLine(text, cursorIndex); + } + + int colonIdx = line.indexOf(':'); + if (colonIdx < 0) { + return resolvePlainTextWord(text, cursorIndex); + } + + String key = line.substring(0, colonIdx) + .trim(); + if (key.isEmpty() || key.startsWith("#")) { + return resolvePlainTextWord(text, cursorIndex); + } + + int lineStart = text.lastIndexOf('\n', cursorIndex - 1) + 1; + int valueStart = colonIdx + 1; + while (valueStart < line.length() && line.charAt(valueStart) == ' ') valueStart++; + + int valueAbsStart = lineStart + valueStart; + int valueAbsEnd = lineStart + line.length(); + + // Cursor is on the value + if (cursorIndex >= valueAbsStart && cursorIndex <= valueAbsEnd) { + String partialText = text.substring(valueAbsStart, cursorIndex); + return new TextSyntaxContext( + SyntaxElementType.WORD, + valueAbsStart, + valueAbsEnd, + new FrontmatterContext(key, true, valueAbsStart, valueAbsEnd, partialText)); + } + + // Cursor is on the key + int keyStart = lineStart + line.indexOf(key); + int keyEnd = keyStart + key.length(); + if (cursorIndex >= keyStart && cursorIndex <= keyEnd) { + return new TextSyntaxContext( + SyntaxElementType.WORD, + keyStart, + keyEnd, + new FrontmatterContext(key, false, keyStart, keyEnd, text.substring(keyStart, cursorIndex))); + } + + return resolvePlainTextWord(text, cursorIndex); + } + + @Nullable + private TextSyntaxContext resolveFrontmatterEmptyLine(String text, int cursorIndex) { + int prevLineEnd = text.lastIndexOf('\n', cursorIndex - 1); + if (prevLineEnd < 0) return resolvePlainTextWord(text, cursorIndex); + int prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1; + String prevLine = text.substring(prevLineStart, prevLineEnd); + String prevTrimmed = prevLine.trim(); + if (prevTrimmed.isEmpty() || prevTrimmed.startsWith("#")) { + return resolvePlainTextWord(text, cursorIndex); + } + + int prevColon = prevLine.indexOf(':'); + if (prevColon < 0) return resolvePlainTextWord(text, cursorIndex); + + String prevKey = prevLine.substring(0, prevColon) + .trim(); + int prevIndent = prevLine.indexOf(prevKey); + if (prevIndent == 0) { + // Top-level key is the direct parent for a list item or empty line. + return new TextSyntaxContext( + SyntaxElementType.WORD, + cursorIndex, + cursorIndex, + new FrontmatterContext(prevKey, true, cursorIndex, cursorIndex, "")); + } + + // Find parent key at a lower indentation + int searchPos = prevLineStart - 1; + while (searchPos > 0) { + int lineEnd = searchPos; + int lineStart = text.lastIndexOf('\n', lineEnd - 1) + 1; + String candidate = text.substring(lineStart, lineEnd); + int cColon = candidate.indexOf(':'); + if (cColon >= 0) { + String cKey = candidate.substring(0, cColon) + .trim(); + if (!cKey.isEmpty() && candidate.indexOf(cKey) < prevIndent) { + int valueStart = cursorIndex; + return new TextSyntaxContext( + SyntaxElementType.WORD, + valueStart, + valueStart, + new FrontmatterContext(cKey, true, valueStart, valueStart, "")); + } + } + searchPos = lineStart - 1; + } + return resolvePlainTextWord(text, cursorIndex); + } + + private static boolean isYamlListMarker(String trimmed) { + if (trimmed.isEmpty()) return false; + char c = trimmed.charAt(0); + if ((c == '-' || c == '*' || c == '+') && (trimmed.length() == 1 || trimmed.charAt(1) == ' ')) return true; + int i = 0; + while (i < trimmed.length() && Character.isDigit(trimmed.charAt(i))) i++; + return i > 0 && i < trimmed.length() && (trimmed.charAt(i) == '.' || trimmed.charAt(i) == ')'); + } + + // ---- Code fence language ---- + + @Nullable + private TextSyntaxContext resolveFenceLanguage(MdAstCode code, String text, int cursorIndex) { + UnistPosition pos = code.position(); + if (pos == null || pos.start() == null) return null; + + int fenceStart = pos.start() + .offset(); + int lineEnd = text.indexOf('\n', fenceStart); + if (lineEnd < 0) lineEnd = text.length(); + + int langStart = fenceStart + 3; + while (langStart < lineEnd && (text.charAt(langStart) == '`' || text.charAt(langStart) == '~')) { + langStart++; + } + langStart = skipSpaces(text, langStart, lineEnd); + + if (cursorIndex < langStart || cursorIndex > lineEnd) return null; + + String partial = text.substring(langStart, cursorIndex); + return new TextSyntaxContext( + SyntaxElementType.FENCE_LANGUAGE, + langStart, + cursorIndex, + new FenceLanguageContext(langStart, cursorIndex, partial)); + } + + // ---- Markdown link/image URL ---- + + @Nullable + private MdAstResource findEnclosingLink(UnistNode node, int cursorIndex) { + MdAstLink link = findEnclosingNode(node, cursorIndex, MdAstLink.class); + if (link != null) return link; + return findEnclosingNode(node, cursorIndex, MdAstImage.class); + } + + @Nullable + private TextSyntaxContext resolveLinkUrl(MdAstResource resource, String text, int cursorIndex) { + UnistPosition pos = ((UnistNode) resource).position(); + if (pos == null || pos.start() == null || pos.end() == null) return null; + + int nodeStart = pos.start() + .offset(); + int nodeEnd = pos.end() + .offset(); + int parenOpen = text.indexOf('(', nodeStart); + if (parenOpen < 0 || parenOpen >= nodeEnd) return null; + int parenClose = text.lastIndexOf(')', nodeEnd - 1); + if (parenClose < parenOpen) return null; + + int urlStart = parenOpen + 1; + int urlEnd = parenClose; + if (cursorIndex < urlStart || cursorIndex > urlEnd) return null; + + String tagName = resource instanceof MdAstImage ? "image" : "link"; + String partial = text.substring(urlStart, cursorIndex); + return new TextSyntaxContext( + SyntaxElementType.ATTRIBUTE_VALUE, + urlStart, + urlEnd, + new MdxValueContext(tagName, "url", urlStart, urlEnd, partial, '\0')); + } + + // ---- Tag start ---- + + @Nullable + private TextSyntaxContext resolveTagStart(String text, int cursorIndex, @Nullable String parentTagName) { + if (cursorIndex < 1) return null; + + char atCursor = text.charAt(cursorIndex - 1); + + // Case 1: cursor immediately after '<' — start of a new tag + if (atCursor == '<') { + if (cursorIndex < text.length()) { + char next = text.charAt(cursorIndex); + if (next == '/' || next == '!' || next == '?') { + return null; + } + } + if (cursorIndex >= 2) { + char prev = text.charAt(cursorIndex - 2); + if (prev != ' ' && prev != '\n' && prev != '\r' && prev != '>' && prev != '\t') { + return null; + } + } + return new TextSyntaxContext( + SyntaxElementType.TAG_START, + cursorIndex, + cursorIndex, + new TagStartContext(cursorIndex, cursorIndex, "", parentTagName)); + } + + // Case 2: cursor inside a partial tag name after '<' (e.g. 0 && isTagNameChar(text.charAt(nameStart - 1))) { + nameStart--; + } + if (nameStart == 0 || text.charAt(nameStart - 1) != '<') { + return null; + } + int tagStart = nameStart - 1; + // Closing tag: tagStart + 1 && text.charAt(tagStart + 1) == '/') { + return null; + } + String partial = text.substring(nameStart, cursorIndex); + return new TextSyntaxContext( + SyntaxElementType.TAG_START, + tagStart, + cursorIndex, + new TagStartContext(nameStart, cursorIndex, partial, parentTagName)); + } + + return null; + } + + private static boolean isTagNameChar(char c) { + return Character.isLetterOrDigit(c) || c == '-'; + } + + // ---- MDX element ---- + + @Nullable + private MdxJsxElementFields findEnclosingMdxElement(UnistNode node, int cursorIndex) { + UnistPosition pos = node.position(); + if (pos != null && pos.start() != null && pos.end() != null) { + if (cursorIndex < pos.start() + .offset() || cursorIndex + > pos.end() + .offset()) { + return null; + } + } + + // Search children first so nested elements are found innermost-first. + if (node instanceof MdAstParent) { + for (UnistNode child : ((MdAstParent) node).children()) { + MdxJsxElementFields found = findEnclosingMdxElement(child, cursorIndex); + if (found != null) return found; + } + } + + if (node instanceof MdxJsxElementFields el && !isRecovered(el)) { + return el; + } + + return null; + } + + private static boolean isRecovered(MdxJsxElementFields el) { + if (el instanceof MdxJsxFlowElement f) return f.recovered; + if (el instanceof MdxJsxTextElement t) return t.recovered; + return false; + } + + @Nullable + private TextSyntaxContext resolveMdxAttribute(MdxJsxElementFields element, String text, int cursorIndex) { + String tagName = element.name(); + if (tagName == null) return resolvePlainTextWord(text, cursorIndex); + + for (var attrNode : element.attributes()) { + if (!(attrNode instanceof MdxJsxAttribute)) continue; + MdxJsxAttribute attr = (MdxJsxAttribute) attrNode; + if (attr.name == null || attr.name.isEmpty()) continue; + + UnistPosition pos = attrNode.position(); + if (pos == null || pos.start() == null || pos.end() == null) continue; + + int attrStart = pos.start() + .offset(); + int attrEnd = pos.end() + .offset(); + + if (cursorIndex < attrStart || cursorIndex > attrEnd) continue; + + TextSyntaxContext valueContext = resolveAttributeValue( + text, + tagName, + attr.name, + attrStart, + attrEnd, + cursorIndex); + if (valueContext != null) return valueContext; + break; + } + + UnistPosition elemPos = element.position(); + if (elemPos != null && elemPos.start() != null && elemPos.end() != null) { + int tagStart = elemPos.start() + .offset(); + int tagEnd = findOpeningTagEnd(text, tagStart); + if (cursorIndex > tagStart && cursorIndex < tagEnd) { + return resolveAttributeNameFromTag(text, tagName, tagStart, tagEnd, cursorIndex); + } + } + + return resolvePlainTextWord(text, cursorIndex); + } + + // ---- Generic AST search ---- + + @SuppressWarnings("unchecked") + @Nullable + private T findEnclosingNode(UnistNode node, int cursorIndex, Class type) { + UnistPosition pos = node.position(); + if (pos != null && pos.start() != null && pos.end() != null) { + if (cursorIndex < pos.start() + .offset() || cursorIndex + > pos.end() + .offset()) { + return null; + } + } + + if (type.isInstance(node)) { + return (T) node; + } + + if (node instanceof MdAstParent) { + for (UnistNode child : ((MdAstParent) node).children()) { + T found = findEnclosingNode(child, cursorIndex, type); + if (found != null) return found; + } + } + + return null; + } + + // ---- Attribute resolution utilities ---- + + private TextSyntaxContext resolvePlainTextWord(String text, int cursorIndex) { + return SyntaxUtils.resolveWord(text, cursorIndex); + } + + @Nullable + private TextSyntaxContext resolveAttributeValue(String text, String tagName, String attrName, int attrStart, + int attrEnd, int cursorIndex) { + int eqIdx = indexOf(text, '=', attrStart, attrEnd); + if (eqIdx < 0) return null; + + int valueStart = skipSpaces(text, eqIdx + 1, attrEnd); + if (valueStart > cursorIndex) return null; + AttributeValueBounds bounds = valueBounds(text, valueStart, attrEnd); + if (cursorIndex < bounds.valueStart || cursorIndex > bounds.valueEnd) return null; + + String partialText = text.substring(bounds.valueStart, cursorIndex); + return new TextSyntaxContext( + SyntaxElementType.ATTRIBUTE_VALUE, + bounds.valueStart, + bounds.valueEnd, + new MdxValueContext( + tagName, + attrName, + bounds.valueStart, + bounds.valueEnd, + partialText, + bounds.missingTerminator)); + } + + @Nullable + private TextSyntaxContext resolveAttributeNameFromTag(String text, String tagName, int tagStart, int tagEnd, + int cursorIndex) { + int scanStart = Math.max(tagStart + 1 + tagName.length(), 0); + if (cursorIndex < scanStart || cursorIndex > tagEnd) return null; + if (isInsideAnyAttributeValue(text, scanStart, tagEnd, cursorIndex)) return null; + + int nameStart = cursorIndex; + while (nameStart > scanStart && isAttributeNameChar(text.charAt(nameStart - 1))) { + nameStart--; + } + int nameEnd = cursorIndex; + while (nameEnd < tagEnd && isAttributeNameChar(text.charAt(nameEnd))) { + nameEnd++; + } + String partial = text.substring(nameStart, cursorIndex); + return new TextSyntaxContext( + SyntaxElementType.ATTRIBUTE_NAME, + nameStart, + nameEnd, + new MdxAttrNameContext(tagName, nameStart, nameEnd, partial)); + } + + // ---- Text scanning helpers ---- + + private static int findOpeningTagEnd(String text, int tagStart) { + boolean inSingle = false; + boolean inDouble = false; + int braceDepth = 0; + for (int i = tagStart; i < text.length(); i++) { + char ch = text.charAt(i); + if ((inSingle || inDouble) && ch == '\\' && i + 1 < text.length()) { + i++; + continue; + } + if (ch == '\'' && !inDouble) { + inSingle = !inSingle; + continue; + } + if (ch == '"' && !inSingle) { + inDouble = !inDouble; + continue; + } + if (inSingle || inDouble) continue; + if (ch == '{') { + braceDepth++; + continue; + } + if (ch == '}') { + braceDepth = Math.max(0, braceDepth - 1); + continue; + } + if (ch == '>' && braceDepth == 0) return i + 1; + } + return text.length(); + } + + private static boolean isInsideAnyAttributeValue(String text, int scanStart, int tagEnd, int cursorIndex) { + int pos = scanStart; + while (pos < tagEnd) { + pos = skipSpaces(text, pos, tagEnd); + if (pos >= tagEnd || !isAttributeNameStart(text.charAt(pos))) { + pos++; + continue; + } + pos++; + while (pos < tagEnd && isAttributeNameChar(text.charAt(pos))) { + pos++; + } + int afterName = skipSpaces(text, pos, tagEnd); + if (afterName >= tagEnd || text.charAt(afterName) != '=') { + pos = afterName; + continue; + } + int rawValueStart = skipSpaces(text, afterName + 1, tagEnd); + AttributeValueBounds bounds = valueBounds(text, rawValueStart, tagEnd); + if (cursorIndex >= rawValueStart && cursorIndex <= bounds.valueEnd) { + return true; + } + pos = Math.max(bounds.rawEnd, rawValueStart + 1); + } + return false; + } + + private static AttributeValueBounds valueBounds(String text, int rawValueStart, int limit) { + if (rawValueStart >= limit) { + return new AttributeValueBounds(rawValueStart, rawValueStart, rawValueStart, '\0'); + } + char open = text.charAt(rawValueStart); + if (open == '"' || open == '\'' || open == '{') { + char close = open == '{' ? '}' : open; + int valueStart = rawValueStart + 1; + int rawEnd = findClosingValue(text, valueStart, limit, close); + boolean closed = rawEnd < limit && text.charAt(rawEnd) == close; + int rawValueEnd = closed ? rawEnd + 1 : rawEnd; + return new AttributeValueBounds(valueStart, rawEnd, rawValueEnd, closed ? '\0' : close); + } + + int rawEnd = rawValueStart; + while (rawEnd < limit) { + char c = text.charAt(rawEnd); + if (Character.isWhitespace(c) || c == '>' || c == '/') { + break; + } + rawEnd++; + } + return new AttributeValueBounds(rawValueStart, rawEnd, rawEnd, '\0'); + } + + private static int findClosingValue(String text, int start, int limit, char close) { + for (int i = start; i < limit; i++) { + char c = text.charAt(i); + if (c == close || c == '>' || c == '\n' || c == '\r') { + return i; + } + } + return limit; + } + + @Nullable + private static String getLineAt(String text, int cursorIndex) { + int lineStart = text.lastIndexOf('\n', cursorIndex - 1) + 1; + int lineEnd = text.indexOf('\n', cursorIndex); + if (lineEnd < 0) lineEnd = text.length(); + if (lineStart >= lineEnd) return null; + return text.substring(lineStart, lineEnd); + } + + private static int indexOf(String text, char target, int start, int end) { + for (int i = start; i < end; i++) { + if (text.charAt(i) == target) return i; + } + return -1; + } + + private static int skipSpaces(String text, int start, int end) { + int pos = start; + while (pos < end && text.charAt(pos) == ' ') { + pos++; + } + return pos; + } + + private static boolean isAttributeNameStart(char c) { + return Character.isLetter(c) || c == '_' || c == ':'; + } + + private static boolean isAttributeNameChar(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == ':' || c == '.'; + } + + private static class AttributeValueBounds { + + private final int valueStart; + private final int valueEnd; + private final int rawEnd; + private final char missingTerminator; + + private AttributeValueBounds(int valueStart, int valueEnd, int rawEnd, char missingTerminator) { + this.valueStart = valueStart; + this.valueEnd = valueEnd; + this.rawEnd = rawEnd; + this.missingTerminator = missingTerminator; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxValueContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxValueContext.java new file mode 100644 index 00000000..caadd287 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/MdxValueContext.java @@ -0,0 +1,55 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +/** Replaces the old MdxAutocompleteContext. Carries tag name, attribute name, and replacement range. */ +public class MdxValueContext implements AutocompleteContext { + + private final String tagName; + private final String attrName; + private final int replaceStart; + private final int replaceEnd; + private final String partialText; + private final char missingValueTerminator; + + public MdxValueContext(String tagName, String attrName, int replaceStart, int replaceEnd, String partialText) { + this(tagName, attrName, replaceStart, replaceEnd, partialText, '\0'); + } + + public MdxValueContext(String tagName, String attrName, int replaceStart, int replaceEnd, String partialText, + char missingValueTerminator) { + this.tagName = tagName; + this.attrName = attrName; + this.replaceStart = replaceStart; + this.replaceEnd = replaceEnd; + this.partialText = partialText; + this.missingValueTerminator = missingValueTerminator; + } + + public String getTagName() { + return tagName; + } + + public String getAttrName() { + return attrName; + } + + @Override + public int replaceStart() { + return replaceStart; + } + + @Override + public int replaceEnd() { + return replaceEnd; + } + + @Override + public String getPartialText() { + return partialText; + } + + public char getMissingValueTerminator() { + return missingValueTerminator; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/SelectionStrategies.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/SelectionStrategies.java new file mode 100644 index 00000000..d8c60996 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/SelectionStrategies.java @@ -0,0 +1,74 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import java.util.EnumMap; +import java.util.Map; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SelectionStrategy; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxElementType; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxUtils; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TextSyntaxContext; + +public class SelectionStrategies { + + private SelectionStrategies() {} + + public static Map defaults() { + EnumMap map = new EnumMap<>(SyntaxElementType.class); + map.put(SyntaxElementType.WORD, new WordSelection()); + map.put(SyntaxElementType.TAG_NAME, new ElementBoundarySelection()); + map.put(SyntaxElementType.TAG_START, new ElementBoundarySelection()); + map.put(SyntaxElementType.ATTRIBUTE_NAME, new ElementBoundarySelection()); + map.put(SyntaxElementType.ATTRIBUTE_VALUE, new ElementBoundarySelection()); + map.put(SyntaxElementType.FENCE_LANGUAGE, new ElementBoundarySelection()); + map.put(SyntaxElementType.OTHER, new NoOpSelection()); + return map; + } + + public static class WordSelection implements SelectionStrategy { + + @Override + public int getSelectionStart(TextSyntaxContext ctx, String text, int cursorIndex) { + int pos = cursorIndex; + while (pos > 0 && SyntaxUtils.isWordChar(text.charAt(pos - 1))) { + pos--; + } + return pos; + } + + @Override + public int getSelectionEnd(TextSyntaxContext ctx, String text, int cursorIndex) { + int pos = cursorIndex; + int len = text.length(); + while (pos < len && SyntaxUtils.isWordChar(text.charAt(pos))) { + pos++; + } + return pos; + } + } + + public static class ElementBoundarySelection implements SelectionStrategy { + + @Override + public int getSelectionStart(TextSyntaxContext ctx, String text, int cursorIndex) { + return ctx.getElementStart(); + } + + @Override + public int getSelectionEnd(TextSyntaxContext ctx, String text, int cursorIndex) { + return ctx.getElementEnd(); + } + } + + public static class NoOpSelection implements SelectionStrategy { + + @Override + public int getSelectionStart(TextSyntaxContext ctx, String text, int cursorIndex) { + return cursorIndex; + } + + @Override + public int getSelectionEnd(TextSyntaxContext ctx, String text, int cursorIndex) { + return cursorIndex; + } + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/TagStartContext.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/TagStartContext.java new file mode 100644 index 00000000..ec73b376 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/TagStartContext.java @@ -0,0 +1,45 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.AutocompleteContext; + +public class TagStartContext implements AutocompleteContext { + + private final int replaceStart; + private final int replaceEnd; + private final String partialText; + @Nullable + private final String parentTagName; + + public TagStartContext(int replaceStart, int replaceEnd, String partialText) { + this(replaceStart, replaceEnd, partialText, null); + } + + public TagStartContext(int replaceStart, int replaceEnd, String partialText, @Nullable String parentTagName) { + this.replaceStart = replaceStart; + this.replaceEnd = replaceEnd; + this.partialText = partialText; + this.parentTagName = parentTagName; + } + + @Nullable + public String getParentTagName() { + return parentTagName; + } + + @Override + public int replaceStart() { + return replaceStart; + } + + @Override + public int replaceEnd() { + return replaceEnd; + } + + @Override + public String getPartialText() { + return partialText; + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/WordBoundaryResolver.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/WordBoundaryResolver.java new file mode 100644 index 00000000..c1d828b5 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/resolver/WordBoundaryResolver.java @@ -0,0 +1,25 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.resolver; + +import org.jetbrains.annotations.Nullable; + +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxContextResolver; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxElementType; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.SyntaxUtils; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.TextSyntaxContext; + +public class WordBoundaryResolver implements SyntaxContextResolver { + + @Override + @Nullable + public TextSyntaxContext resolve(String text, int cursorIndex) { + if (text == null || text.isEmpty() || cursorIndex < 0 || cursorIndex > text.length()) { + return null; + } + + if (!SyntaxUtils.isWordChar(text.charAt(Math.min(cursorIndex, text.length() - 1)))) { + return new TextSyntaxContext(SyntaxElementType.OTHER, cursorIndex, cursorIndex, null); + } + + return SyntaxUtils.resolveWord(text, cursorIndex); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/ui/AutocompletePopup.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/ui/AutocompletePopup.java new file mode 100644 index 00000000..a24be30c --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/autocomplete/ui/AutocompletePopup.java @@ -0,0 +1,306 @@ +package com.hfstudio.guidenh.guide.internal.editor.autocomplete.ui; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.FontRenderer; +import net.minecraft.client.gui.Gui; + +import org.jetbrains.annotations.Nullable; +import org.lwjgl.opengl.GL11; + +import com.hfstudio.guidenh.guide.document.LytRect; +import com.hfstudio.guidenh.guide.internal.editor.autocomplete.provider.AutocompleteCandidate; +import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorPopupLayout; +import com.hfstudio.guidenh.guide.internal.util.DisplayScale; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; + +public class AutocompletePopup { + + public static final int MAX_VISIBLE_ITEMS = 5; + public static final int PADDING_X = 6; + public static final int PADDING_Y = 4; + public static final int SCROLLBAR_W = 5; + public static final int BACKGROUND_COLOR = 0xF0181C22; + public static final int BORDER_COLOR = 0xFF4D5661; + public static final int HOVER_COLOR = 0xCC2A3A46; + public static final int SCROLLBAR_TRACK_COLOR = 0x35101010; + public static final int SCROLLBAR_THUMB_COLOR = 0xA0D8D8D8; + /** Gap between popup and cursor when flipped above (roughly FONT_HEIGHT + cursor gap). */ + private static final int FLIP_GAP = 22; + + private boolean open; + private List candidates = Collections.emptyList(); + private List lastCandidateKeys = Collections.emptyList(); + private int selectedIndex; + private int scrollY; + private final SmoothFloatState visualScrollY = new SmoothFloatState(); + private int x, y, width, height; + private int viewportWidth, viewportHeight; + + public boolean isOpen() { + return open; + } + + public void show(List candidates, int anchorX, int anchorY, int viewportWidth, + int viewportHeight, FontRenderer fontRenderer) { + this.candidates = candidates != null ? candidates : Collections.emptyList(); + + if (this.candidates.isEmpty()) { + open = false; + return; + } + + // Preserve scroll/selection when candidate list hasn't changed + boolean sameList = keysMatch(this.candidates, lastCandidateKeys); + if (!sameList) { + this.selectedIndex = 0; + this.scrollY = 0; + lastCandidateKeys = candidateKeys(this.candidates); + computeSize(fontRenderer); + snapVisualScrollToTarget(); + } + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + + placePopup(anchorX, anchorY, viewportWidth, viewportHeight); + if (selectedIndex >= this.candidates.size()) selectedIndex = 0; + this.open = true; + } + + private static List candidateKeys(List list) { + List keys = new ArrayList<>(list.size()); + for (AutocompleteCandidate c : list) keys.add(c.displayText()); + return keys; + } + + private static boolean keysMatch(List list, List keys) { + if (list.size() != keys.size()) return false; + for (int i = 0; i < list.size(); i++) { + if (!list.get(i) + .displayText() + .equals(keys.get(i))) return false; + } + return true; + } + + public void close() { + open = false; + candidates = Collections.emptyList(); + lastCandidateKeys = Collections.emptyList(); + selectedIndex = -1; + snapVisualScrollToTarget(); + } + + public void moveSelection(int delta) { + if (!open || candidates.isEmpty()) return; + int next = selectedIndex + delta; + if (next < 0) next = 0; + if (next >= candidates.size()) next = candidates.size() - 1; + selectedIndex = next; + ensureSelectionVisible(); + } + + @Nullable + public AutocompleteCandidate getSelected() { + if (!open || selectedIndex < 0 || selectedIndex >= candidates.size()) return null; + return candidates.get(selectedIndex); + } + + public void scrollWheel(int delta) { + if (!open) return; + int maxH = computeMaxItemHeight(); + scrollY = clampScroll(scrollY - delta * maxH * 2); + } + + /** Recompute popup position without changing candidates or selection. */ + public void reposition(int anchorX, int anchorY, int viewportWidth, int viewportHeight, FontRenderer fontRenderer) { + if (!open) return; + placePopup(anchorX, anchorY, viewportWidth, viewportHeight); + this.scrollY = clampScroll(scrollY); + } + + /** Place popup below the anchor; flip above if it doesn't fit. */ + private void placePopup(int anchorX, int anchorY, int viewportWidth, int viewportHeight) { + // Try below cursor first + LytRect below = SceneEditorPopupLayout + .clampToViewport(anchorX, anchorY, width, height, viewportWidth, viewportHeight, 2); + if (below.y() >= anchorY - 2) { + this.x = below.x(); + this.y = below.y(); + return; + } + // Not enough room below �?flip above cursor + int aboveY = anchorY - height - FLIP_GAP; + LytRect above = SceneEditorPopupLayout + .clampToViewport(anchorX, aboveY, width, height, viewportWidth, viewportHeight, 2); + this.x = above.x(); + this.y = above.y(); + } + + public boolean mouseClicked(int mouseX, int mouseY) { + if (!open || !contains(mouseX, mouseY)) { + return false; + } + if (isInsideScrollbar(mouseX, mouseY)) { + int contentHeight = computeContentHeight(); + if (contentHeight > height) { + int trackH = Math.max(1, height - 2); + int maxScroll = Math.max(0, contentHeight - height); + scrollY = (mouseY - y) * maxScroll / trackH; + scrollY = clampScroll(scrollY); + } + return true; + } + int index = findItemIndex(mouseX, mouseY); + if (index >= 0 && index < candidates.size()) { + selectedIndex = index; + } + return true; + } + + public boolean contains(int mouseX, int mouseY) { + return mouseX >= x && mouseX < x + width && mouseY >= y && mouseY < y + height; + } + + public void draw(FontRenderer fontRenderer, int mouseX, int mouseY) { + if (!open) return; + updateVisualScroll(); + int renderedScrollY = visualScrollY.rounded(); + + Gui.drawRect(x - 1, y - 1, x + width + 1, y + height + 1, BORDER_COLOR); + Gui.drawRect(x, y, x + width, y + height, BACKGROUND_COLOR); + + pushScissor(x + 1, y + 1, width - 2, height - 2); + + int drawY = y + PADDING_Y - renderedScrollY; + int itemIndex = 0; + int itemHeight = computeMaxItemHeight(); + for (AutocompleteCandidate candidate : candidates) { + if (drawY + itemHeight >= y + 1 && drawY <= y + height - 1) { + if (itemIndex == selectedIndex) { + Gui.drawRect(x + 1, drawY - 1, x + width - SCROLLBAR_W - 1, drawY + itemHeight - 1, HOVER_COLOR); + } + candidate.render( + fontRenderer, + x + PADDING_X, + drawY, + width - PADDING_X * 2 - (hasScrollbar() ? SCROLLBAR_W : 0), + itemIndex == selectedIndex); + } + itemIndex++; + drawY += itemHeight; + } + + popScissor(); + drawScrollbar(); + } + + private void computeSize(FontRenderer fontRenderer) { + int maxW = 72; + int maxItemH = 14; + for (AutocompleteCandidate c : candidates) { + int textW = fontRenderer.getStringWidth(c.displayText()) + PADDING_X * 2; + int renderW = c.renderWidth(fontRenderer); + int w = Math.max(textW, renderW > 0 ? renderW + PADDING_X * 2 : 0); + + if (w > maxW) maxW = w; + if (c.renderHeight() > maxItemH) maxItemH = c.renderHeight(); + } + this.width = Math.max(72, maxW + SCROLLBAR_W); + int visibleItems = Math.min(candidates.size(), MAX_VISIBLE_ITEMS); + this.height = visibleItems * maxItemH + PADDING_Y * 2; + } + + private int computeMaxItemHeight() { + int max = 14; + for (AutocompleteCandidate c : candidates) { + if (c.renderHeight() > max) max = c.renderHeight(); + } + return max; + } + + private int computeContentHeight() { + int h = 0; + int maxH = computeMaxItemHeight(); + for (int i = 0; i < candidates.size(); i++) { + h += maxH; + } + return h + PADDING_Y * 2; + } + + private boolean hasScrollbar() { + return candidates.size() > MAX_VISIBLE_ITEMS; + } + + private void ensureSelectionVisible() { + int itemH = computeMaxItemHeight(); + int selTop = selectedIndex * itemH; + int selBottom = selTop + itemH; + int viewH = height - PADDING_Y * 2; + if (selTop < scrollY) { + scrollY = selTop; + } else if (selBottom > scrollY + viewH) { + scrollY = selBottom - viewH; + } + scrollY = clampScroll(scrollY); + } + + private int findItemIndex(int mouseX, int mouseY) { + if (!contains(mouseX, mouseY)) return -1; + int itemH = computeMaxItemHeight(); + int localY = mouseY - y - PADDING_Y + visualScrollY.rounded(); + int index = localY / itemH; + if (index < 0 || index >= candidates.size()) return -1; + return index; + } + + private void drawScrollbar() { + int contentH = computeContentHeight(); + if (contentH <= height) return; + int barX = x + width - SCROLLBAR_W; + Gui.drawRect(barX, y + 1, x + width - 1, y + height - 1, SCROLLBAR_TRACK_COLOR); + int thumbH = Math.max(16, height * height / Math.max(1, contentH)); + int maxScroll = Math.max(0, contentH - height); + int thumbY = y + (maxScroll > 0 ? (height - thumbH) * visualScrollY.rounded() / maxScroll : 0); + Gui.drawRect(barX, thumbY, x + width - 1, thumbY + thumbH, SCROLLBAR_THUMB_COLOR); + } + + private boolean isInsideScrollbar(int mouseX, int mouseY) { + if (!hasScrollbar()) return false; + return mouseX >= x + width - SCROLLBAR_W && mouseX < x + width && mouseY >= y && mouseY < y + height; + } + + private int clampScroll(int value) { + int maxScroll = Math.max(0, computeContentHeight() - height); + if (value < 0) return 0; + return Math.min(value, maxScroll); + } + + private void snapVisualScrollToTarget() { + visualScrollY.snapTo(scrollY); + } + + private void updateVisualScroll() { + visualScrollY.updateTowards(scrollY, 28f, 0.25f, 0.01f, Math.max(96f, height * 2f)); + } + + private static void pushScissor(int x, int y, int width, int height) { + Minecraft mc = Minecraft.getMinecraft(); + int scale = DisplayScale.scaleFactor(); + GL11.glEnable(GL11.GL_SCISSOR_TEST); + GL11.glScissor( + x * scale, + mc.displayHeight - (y + height) * scale, + Math.max(0, width * scale), + Math.max(0, height * scale)); + } + + private static void popScissor() { + GL11.glDisable(GL11.GL_SCISSOR_TEST); + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glColor4f(1f, 1f, 1f, 1f); + } +} 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 dcaf5417..12106bd3 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 @@ -3,6 +3,7 @@ import java.awt.Toolkit; import java.awt.datatransfer.DataFlavor; import java.awt.datatransfer.StringSelection; +import java.util.Collections; import java.util.List; import net.minecraft.client.gui.FontRenderer; @@ -10,10 +11,23 @@ import net.minecraft.client.gui.GuiScreen; import net.minecraft.client.gui.GuiTextField; +import org.jetbrains.annotations.Nullable; import org.lwjgl.input.Keyboard; import org.lwjgl.opengl.GL11; +import com.hfstudio.guidenh.guide.compiler.GuideMarkdownOptions; +import com.hfstudio.guidenh.guide.internal.markdown.MdAstToMdxConverter; import com.hfstudio.guidenh.guide.internal.util.DisplayScale; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; +import com.hfstudio.guidenh.libs.mdast.MdAst; +import com.hfstudio.guidenh.libs.mdast.model.MdAstDefinition; +import com.hfstudio.guidenh.libs.mdast.model.MdAstList; +import com.hfstudio.guidenh.libs.mdast.model.MdAstListContent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstListItem; +import com.hfstudio.guidenh.libs.mdast.model.MdAstParent; +import com.hfstudio.guidenh.libs.mdast.model.MdAstRoot; +import com.hfstudio.guidenh.libs.unist.UnistNode; +import com.hfstudio.guidenh.libs.unist.UnistPosition; public class SceneEditorMultilineTextArea { @@ -44,13 +58,18 @@ public class SceneEditorMultilineTextArea { private int textViewportWidth; private int textViewportHeight; private int horizontalOffsetPixels; + private final SmoothFloatState visualVerticalOffsetPixels = new SmoothFloatState(); + private final SmoothFloatState visualHorizontalOffsetPixels = new SmoothFloatState(); private boolean wrapEnabled; private boolean verticalScrollbarVisible; private boolean horizontalScrollbarVisible; private boolean focused; private boolean selectingWithMouse; + private boolean panningWithMiddleMouse; private boolean draggingVerticalScrollbar; private boolean draggingHorizontalScrollbar; + private int panLastMouseX; + private int panLastMouseY; private int verticalScrollbarGrabOffset; private int horizontalScrollbarGrabOffset; private int externalHighlightStart; @@ -61,6 +80,12 @@ public class SceneEditorMultilineTextArea { private int recentPhysicalAsciiChar; private long recentPhysicalAsciiAtMillis; + // Double-click word selection + private long lastClickTimeMillis; + private static final long DOUBLE_CLICK_WINDOW_MS = 400; + @Nullable + private DoubleClickHandler doubleClickHandler; + public SceneEditorMultilineTextArea(FontRenderer fontRenderer) { this(fontRenderer, new ClipboardAccess() { @@ -94,6 +119,7 @@ public SceneEditorMultilineTextArea(FontRenderer fontRenderer, ClipboardAccess c this.wrapEnabled = true; this.focused = false; this.selectingWithMouse = false; + this.panningWithMiddleMouse = false; this.draggingVerticalScrollbar = false; this.draggingHorizontalScrollbar = false; this.verticalScrollbarGrabOffset = 0; @@ -122,7 +148,7 @@ public void setBounds(int x, int y, int width, int height) { } public void setText(String text) { - String safeText = text != null ? text : ""; + String safeText = normalizeLineEndings(text); if (selectionModel.getText() .equals(safeText)) { return; @@ -187,7 +213,7 @@ public boolean cutSelection() { } public boolean pasteClipboard() { - selectionModel.insertText(clipboardAccess.paste()); + selectionModel.insertText(normalizeLineEndings(clipboardAccess.paste())); rebuildLayoutCache(); ensureCursorVisible(); syncImeFocusProxy(); @@ -195,7 +221,7 @@ public boolean pasteClipboard() { } public void applyEdit(String text, int selectionStart, int selectionEnd) { - selectionModel.setText(text != null ? text : ""); + selectionModel.setText(normalizeLineEndings(text)); selectionModel.setSelection(selectionStart, selectionEnd); rebuildLayoutCache(); ensureCursorVisible(); @@ -203,7 +229,7 @@ public void applyEdit(String text, int selectionStart, int selectionEnd) { } public void insertAtSelection(String text) { - selectionModel.insertText(text); + selectionModel.insertText(normalizeLineEndings(text)); rebuildLayoutCache(); ensureCursorVisible(); syncImeFocusProxy(); @@ -306,6 +332,7 @@ public void setVerticalScrollFraction(float fraction) { int maxOffset = Math.max(0, scrollState.getContentPixels() - scrollState.getViewportPixels()); int offset = Math.round(Math.clamp(fraction, 0f, 1f) * maxOffset); scrollState.setOffsetPixels(offset); + snapVisualOffsetsToTarget(); syncImeFocusProxy(); } @@ -358,6 +385,7 @@ public void setFocused(boolean focused) { imeFocusProxy.setFocused(focused); if (!focused) { selectingWithMouse = false; + panningWithMiddleMouse = false; draggingVerticalScrollbar = false; draggingHorizontalScrollbar = false; } @@ -384,6 +412,16 @@ public boolean mouseClicked(int mouseX, int mouseY, int button) { if (!contains(mouseX, mouseY)) { return false; } + if (button == 2) { + setFocused(true); + panningWithMiddleMouse = true; + selectingWithMouse = false; + draggingVerticalScrollbar = false; + draggingHorizontalScrollbar = false; + panLastMouseX = mouseX; + panLastMouseY = mouseY; + return true; + } if (button != 0 && button != 1) { return true; } @@ -432,6 +470,13 @@ public boolean mouseClicked(int mouseX, int mouseY, int button) { int cursorIndex = getCursorIndexAt(mouseX, mouseY); if (button == 0) { selectionModel.beginSelection(cursorIndex); + long now = System.currentTimeMillis(); + long elapsed = now - lastClickTimeMillis; + lastClickTimeMillis = now; + if (elapsed < DOUBLE_CLICK_WINDOW_MS && doubleClickHandler != null) { + doubleClickHandler.onDoubleClick(selectionModel.getCursorIndex()); + return true; + } selectingWithMouse = true; } else { selectionModel.setCursorIndex(cursorIndex); @@ -469,6 +514,16 @@ public boolean mouseDragged(int mouseX, int mouseY, int button) { syncImeFocusProxy(); return true; } + if (button == 2 && panningWithMiddleMouse) { + int deltaX = mouseX - panLastMouseX; + int deltaY = mouseY - panLastMouseY; + panLastMouseX = mouseX; + panLastMouseY = mouseY; + horizontalOffsetPixels = clampHorizontalOffset(horizontalOffsetPixels - deltaX); + scrollState.scrollPixels(-deltaY); + syncImeFocusProxy(); + return true; + } if (!focused || button != 0 || !selectingWithMouse) { return false; } @@ -484,6 +539,9 @@ public void mouseReleased(int button) { draggingVerticalScrollbar = false; draggingHorizontalScrollbar = false; } + if (button == 2) { + panningWithMiddleMouse = false; + } } public boolean keyTyped(char typedChar, int keyCode) { @@ -522,7 +580,7 @@ private boolean handleKeyTyped(char typedChar, int keyCode) { return true; } if (isCtrlKeyCombo(keyCode, Keyboard.KEY_V)) { - selectionModel.insertText(clipboardAccess.paste()); + selectionModel.insertText(normalizeLineEndings(clipboardAccess.paste())); rebuildLayoutCache(); ensureCursorVisible(); return true; @@ -531,7 +589,7 @@ private boolean handleKeyTyped(char typedChar, int keyCode) { switch (keyCode) { case Keyboard.KEY_RETURN: case Keyboard.KEY_NUMPADENTER: - selectionModel.insertText("\n"); + applySmartNewline(); rebuildLayoutCache(); ensureCursorVisible(); return true; @@ -633,7 +691,279 @@ private void rememberInsertedAsciiCharacter(char typedChar, int keyCode) { recentPhysicalAsciiAtMillis = System.currentTimeMillis(); } + private void applySmartNewline() { + String text = selectionModel.getText(); + int cursor = selectionModel.getCursorIndex(); + + // 1. Try AST-based list continuation + MdAstRoot root = MdAst.fromMarkdown(text, GuideMarkdownOptions.runtime()); + MdAstToMdxConverter.convert(root, Collections.emptyMap()); + MdAstListItem item = findEnclosingListItem(root, cursor); + if (item != null) { + int lineStart = findLineStart(text, cursor - 1); + if (isListItemContentEmpty(item, text)) { + selectionModel.setSelection(lineStart, cursor); + selectionModel.insertText(leadingWhitespace(text.substring(lineStart, findLineEnd(text, cursor)))); + return; + } + String nextMarker = resolveNextListMarker(text, item, root); + if (nextMarker != null) { + selectionModel.insertText("\n" + nextMarker); + return; + } + } + + // 2. Fallback: manual list marker continuation (for YAML and non-Markdown lists) + int lineStart = findLineStart(text, Math.max(0, cursor - 1)); + int lineEnd = findLineEnd(text, cursor); + String line = text.substring(lineStart, lineEnd); + String indent = leadingWhitespace(line); + String trimmed = line.trim(); + String manualMarker = resolveManualListMarker(trimmed); + if (manualMarker != null) { + int markerLen = manualMarker.length(); + if (trimmed.length() <= markerLen || (trimmed.length() > markerLen && trimmed.substring(markerLen) + .trim() + .isEmpty())) { + // Empty list item: remove marker + selectionModel.setSelection(lineStart, cursor); + selectionModel.insertText(indent); + return; + } + selectionModel.insertText("\n" + indent + manualMarker); + return; + } + if (trimmed.isEmpty()) { + // Blank line: move cursor to next line instead of inserting another blank line. + int nextLineStart = findLineEnd(text, cursor) + 1; + if (nextLineStart <= text.length()) { + selectionModel.setSelection(nextLineStart, nextLineStart); + } + return; + } + selectionModel.insertText("\n" + indent); + } + + @Nullable + private static String resolveManualListMarker(String trimmed) { + if (trimmed.isEmpty()) return null; + char first = trimmed.charAt(0); + if (first == '-' || first == '*' || first == '+') { + if (trimmed.length() >= 2 && trimmed.charAt(1) == ' ') return "- "; + if (trimmed.length() == 1) return "- "; // bare marker, empty item + } + // Ordered list: number. or number) + int i = 0; + while (i < trimmed.length() && Character.isDigit(trimmed.charAt(i))) i++; + if (i > 0 && i + 1 < trimmed.length() + && (trimmed.charAt(i) == '.' || trimmed.charAt(i) == ')') + && trimmed.charAt(i + 1) == ' ') { + int num = Integer.parseInt(trimmed.substring(0, i)); + return (num + 1) + trimmed.charAt(i) + " "; + } + return null; + } + + @Nullable + private static MdAstListItem findEnclosingListItem(UnistNode node, int cursorIndex) { + UnistPosition pos = node.position(); + if (pos != null && pos.start() != null && pos.end() != null) { + if (cursorIndex < pos.start() + .offset() || cursorIndex + > pos.end() + .offset()) { + return null; + } + } + if (node instanceof MdAstListItem) { + return (MdAstListItem) node; + } + if (node instanceof MdAstParent) { + for (UnistNode child : ((MdAstParent) node).children()) { + MdAstListItem found = findEnclosingListItem(child, cursorIndex); + if (found != null) return found; + } + } + return null; + } + + private static boolean isListItemContentEmpty(MdAstListItem item, String text) { + UnistPosition pos = item.position(); + if (pos == null || pos.start() == null || pos.end() == null) return false; + int start = pos.start() + .offset(); + int end = pos.end() + .offset(); + if (start >= end || end > text.length()) return false; + // Find first newline to get the first line (contains the marker) + int firstLineEnd = text.indexOf('\n', start); + if (firstLineEnd < 0 || firstLineEnd >= end) firstLineEnd = end; + // Skip past the marker: find the first non-digit/non-marker char after indent + String firstLine = text.substring(start, firstLineEnd); + String trimmed = firstLine.trim(); + if (trimmed.isEmpty()) return true; + // Check if after the marker the content is empty + int contentStart = findListContentStart(trimmed); + return contentStart >= trimmed.length() || trimmed.substring(contentStart) + .trim() + .isEmpty(); + } + + private static int findListContentStart(String trimmed) { + int i = 0; + // Unordered: -, *, + + if (i < trimmed.length() + && (trimmed.charAt(i) == '-' || trimmed.charAt(i) == '*' || trimmed.charAt(i) == '+')) { + i++; + if (i < trimmed.length() && trimmed.charAt(i) == ' ') return i + 1; + } + // Ordered: number. or number) + while (i < trimmed.length() && Character.isDigit(trimmed.charAt(i))) i++; + if (i > 0 && i + 1 < trimmed.length() + && (trimmed.charAt(i) == '.' || trimmed.charAt(i) == ')') + && trimmed.charAt(i + 1) == ' ') { + return i + 2; + } + return 0; + } + + @Nullable + private static String resolveNextListMarker(String text, MdAstListItem item, MdAstRoot root) { + MdAstList list = findParentList(root, item); + if (list == null) return null; + // Find the marker from the current item's source text + UnistPosition pos = item.position(); + if (pos == null || pos.start() == null) return null; + int itemStart = pos.start() + .offset(); + int firstLineEnd = text.indexOf('\n', itemStart); + if (firstLineEnd < 0) firstLineEnd = Math.min(itemStart + 80, text.length()); + String firstLine = text.substring(itemStart, Math.min(firstLineEnd, text.length())) + .trim(); + String marker = extractListMarker(firstLine); + if (marker == null) return null; + + if (list.ordered) { + // Compute next number: find the index of this item in the list's children + int index = 0; + for (MdAstListContent child : list.children()) { + if (child == item) break; + if (child instanceof MdAstListItem) index++; + } + int nextNumber = list.start + index + 1; + char delimiter = marker.charAt(marker.length() - 1); // . or ) + return indentFor(item) + nextNumber + delimiter + " "; + } + return indentFor(item) + marker; + } + + @Nullable + private static String extractListMarker(String firstLine) { + if (firstLine.isEmpty()) return null; + int i = 0; + char c = firstLine.charAt(i); + if (c == '-' || c == '*' || c == '+') { + return i + 1 < firstLine.length() && firstLine.charAt(i + 1) == ' ' ? "- " : null; + } + while (i < firstLine.length() && Character.isDigit(firstLine.charAt(i))) i++; + if (i > 0 && i + 1 < firstLine.length() + && (firstLine.charAt(i) == '.' || firstLine.charAt(i) == ')') + && firstLine.charAt(i + 1) == ' ') { + return firstLine.substring(0, i) + firstLine.charAt(i) + " "; + } + return null; + } + + private static String indentFor(MdAstListItem item) { + // Simple: use empty indent (list items are typically left-aligned) + return ""; + } + + @Nullable + private static MdAstList findParentList(UnistNode root, MdAstListItem target) { + if (!(root instanceof MdAstParent)) return null; + for (UnistNode child : ((MdAstParent) root).children()) { + if (child instanceof MdAstList) { + for (MdAstListContent item : ((MdAstList) child).children()) { + if (item == target) return (MdAstList) child; + } + MdAstList found = findParentList(child, target); + if (found != null) return found; + } else { + MdAstList found = findParentList(child, target); + if (found != null) return found; + } + } + return null; + } + + private static int findLineStart(String text, int index) { + int pos = Math.min(index, text.length()); + while (pos > 0) { + char previous = text.charAt(pos - 1); + if (previous == '\n' || previous == '\r') { + break; + } + pos--; + } + return pos; + } + + private static int findLineEnd(String text, int index) { + int pos = Math.min(index, text.length()); + while (pos < text.length()) { + char current = text.charAt(pos); + if (current == '\n' || current == '\r') { + break; + } + pos++; + } + return pos; + } + + private static String leadingWhitespace(String line) { + int end = 0; + while (end < line.length()) { + char c = line.charAt(end); + if (c != ' ' && c != '\t') { + break; + } + end++; + } + return line.substring(0, end); + } + + private static String normalizeLineEndings(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + StringBuilder normalized = null; + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c != '\r') { + if (normalized != null) { + normalized.append(c); + } + continue; + } + if (normalized == null) { + normalized = new StringBuilder(text.length()); + normalized.append(text, 0, i); + } + if (i + 1 < text.length() && text.charAt(i + 1) == '\n') { + normalized.append('\n'); + i++; + } else { + normalized.append('\n'); + } + } + return normalized != null ? normalized.toString() : text; + } + public void draw(boolean validationError) { + updateVisualOffsets(); + int renderedVerticalOffset = visualVerticalOffsetPixels.rounded(); + int renderedHorizontalOffset = visualHorizontalOffsetPixels.rounded(); int borderColor = validationError ? ERROR_BORDER_COLOR : (focused ? FOCUSED_BORDER_COLOR : BORDER_COLOR); Gui.drawRect(x - 1, y - 1, x + width + 1, y + height + 1, borderColor); Gui.drawRect(x, y, x + width, y + height, BACKGROUND_COLOR); @@ -650,13 +980,13 @@ public void draw(boolean validationError) { List lines = layoutCache.getVisualLines(); int lineHeight = getLineHeight(); - int drawY = y + PADDING - scrollState.getOffsetPixels(); + int drawY = y + PADDING - renderedVerticalOffset; for (SceneEditorMultilineTextLayoutCache.VisualLine line : lines) { if (drawY + lineHeight >= y && drawY < y + clipHeight) { - drawExternalHighlightForLine(line, drawY); - drawSelectionForLine(line, drawY); - fontRenderer.drawString(line.text(), x + PADDING - horizontalOffsetPixels, drawY, 0xF0F0F0); - drawSyntaxWarningForLine(line, drawY); + drawExternalHighlightForLine(line, drawY, renderedHorizontalOffset); + drawSelectionForLine(line, drawY, renderedHorizontalOffset); + fontRenderer.drawString(line.text(), x + PADDING - renderedHorizontalOffset, drawY, 0xF0F0F0); + drawSyntaxWarningForLine(line, drawY, renderedHorizontalOffset); } drawY += lineHeight; } @@ -665,8 +995,8 @@ public void draw(boolean validationError) { int cursorLine = getVisualLineIndex(selectionModel.getCursorIndex()); SceneEditorMultilineTextLayoutCache.VisualLine visualLine = lines.get(cursorLine); int cursorPixel = getCursorPixelOnLine(selectionModel.getCursorIndex(), visualLine); - int cursorX = x + PADDING + cursorPixel - horizontalOffsetPixels; - int cursorY = y + PADDING + cursorLine * lineHeight - scrollState.getOffsetPixels(); + int cursorX = x + PADDING + cursorPixel - renderedHorizontalOffset; + int cursorY = y + PADDING + cursorLine * lineHeight - renderedVerticalOffset; Gui.drawRect(cursorX, cursorY, cursorX + 1, cursorY + fontRenderer.FONT_HEIGHT + 1, 0xFFFFFFFF); } @@ -712,19 +1042,22 @@ private void rebuildLayoutCache() { } scrollState.setViewportPixels(textViewportHeight); scrollState.setContentPixels(layoutCache.getContentHeightPixels()); + snapVisualOffsetsToTarget(); } private void syncImeFocusProxy() { int lineHeight = getLineHeight(); int cursorX = x + PADDING; int cursorY = y + PADDING; + int renderedVerticalOffset = visualVerticalOffsetPixels.rounded(); + int renderedHorizontalOffset = visualHorizontalOffsetPixels.rounded(); List lines = layoutCache.getVisualLines(); if (!lines.isEmpty()) { int cursorLine = getVisualLineIndex(selectionModel.getCursorIndex()); SceneEditorMultilineTextLayoutCache.VisualLine visualLine = lines.get(cursorLine); int cursorPixel = getCursorPixelOnLine(selectionModel.getCursorIndex(), visualLine); - cursorX += cursorPixel - horizontalOffsetPixels; - cursorY += cursorLine * lineHeight - scrollState.getOffsetPixels(); + cursorX += cursorPixel - renderedHorizontalOffset; + cursorY += cursorLine * lineHeight - renderedVerticalOffset; } int contentLeft = x + PADDING; @@ -739,7 +1072,8 @@ private void syncImeFocusProxy() { imeFocusProxy.height = Math.max(1, lineHeight); } - private void drawSelectionForLine(SceneEditorMultilineTextLayoutCache.VisualLine line, int drawY) { + private void drawSelectionForLine(SceneEditorMultilineTextLayoutCache.VisualLine line, int drawY, + int renderedHorizontalOffset) { if (!selectionModel.hasSelection()) { return; } @@ -758,7 +1092,7 @@ private void drawSelectionForLine(SceneEditorMultilineTextLayoutCache.VisualLine .substring(0, max); String selectedText = line.text() .substring(max, Math.max(0, highlightEnd - line.startIndex())); - int selectionX = x + PADDING + fontRenderer.getStringWidth(beforeSelection) - horizontalOffsetPixels; + int selectionX = x + PADDING + fontRenderer.getStringWidth(beforeSelection) - renderedHorizontalOffset; int selectionWidth = fontRenderer.getStringWidth(selectedText); if (selectionWidth <= 0 && spansLineBreak) { selectionWidth = 2; @@ -773,7 +1107,8 @@ private void drawSelectionForLine(SceneEditorMultilineTextLayoutCache.VisualLine } } - private void drawExternalHighlightForLine(SceneEditorMultilineTextLayoutCache.VisualLine line, int drawY) { + private void drawExternalHighlightForLine(SceneEditorMultilineTextLayoutCache.VisualLine line, int drawY, + int renderedHorizontalOffset) { if (externalHighlightStart < 0 || externalHighlightEnd <= externalHighlightStart) { return; } @@ -790,7 +1125,7 @@ private void drawExternalHighlightForLine(SceneEditorMultilineTextLayoutCache.Vi .substring(0, max); String highlightedText = line.text() .substring(max, Math.max(0, highlightEnd - line.startIndex())); - int highlightX = x + PADDING + fontRenderer.getStringWidth(beforeHighlight) - horizontalOffsetPixels; + int highlightX = x + PADDING + fontRenderer.getStringWidth(beforeHighlight) - renderedHorizontalOffset; int highlightWidth = fontRenderer.getStringWidth(highlightedText); if (highlightWidth <= 0 && spansLineBreak) { highlightWidth = 2; @@ -805,7 +1140,8 @@ private void drawExternalHighlightForLine(SceneEditorMultilineTextLayoutCache.Vi } } - private void drawSyntaxWarningForLine(SceneEditorMultilineTextLayoutCache.VisualLine line, int drawY) { + private void drawSyntaxWarningForLine(SceneEditorMultilineTextLayoutCache.VisualLine line, int drawY, + int renderedHorizontalOffset) { if (syntaxWarningStart < 0 || syntaxWarningEnd <= syntaxWarningStart) { return; } @@ -822,7 +1158,7 @@ private void drawSyntaxWarningForLine(SceneEditorMultilineTextLayoutCache.Visual .substring(0, max); String warnedText = line.text() .substring(max, Math.max(0, highlightEnd - line.startIndex())); - int warningX = x + PADDING + fontRenderer.getStringWidth(beforeWarning) - horizontalOffsetPixels; + int warningX = x + PADDING + fontRenderer.getStringWidth(beforeWarning) - renderedHorizontalOffset; int warningWidth = fontRenderer.getStringWidth(warnedText); if (warningWidth <= 0 && spansLineBreak) { warningWidth = 2; @@ -883,7 +1219,7 @@ private SceneEditorVerticalScrollbar.Thumb getVerticalScrollbarThumb() { getVerticalScrollbarTrackLength(), scrollState.getContentPixels(), scrollState.getViewportPixels(), - scrollState.getOffsetPixels()); + visualVerticalOffsetPixels.rounded()); } private SceneEditorHorizontalScrollbar.Thumb getHorizontalScrollbarThumb() { @@ -895,7 +1231,7 @@ private SceneEditorHorizontalScrollbar.Thumb getHorizontalScrollbarThumb() { getHorizontalScrollbarTrackLength(), layoutCache.getContentWidthPixels(), textViewportWidth, - horizontalOffsetPixels); + visualHorizontalOffsetPixels.rounded()); } private void moveCursorVertical(int direction, boolean keepSelection) { @@ -925,12 +1261,41 @@ private void moveCursorToLineBoundary(boolean start, boolean keepSelection) { ensureCursorVisible(); } + public int getCursorIndexAtPublic(int mouseX, int mouseY) { + return getCursorIndexAt(mouseX, mouseY); + } + + /** Returns the pixel X position of the cursor relative to this text area. */ + public int getCursorPixelX() { + List lines = layoutCache.getVisualLines(); + if (lines.isEmpty()) return PADDING; + int lineIdx = getVisualLineIndex(selectionModel.getCursorIndex()); + return PADDING + getCursorPixelOnLine(selectionModel.getCursorIndex(), lines.get(lineIdx)) + - visualHorizontalOffsetPixels.rounded(); + } + + /** Returns the pixel Y position of the cursor relative to this text area. */ + public int getCursorPixelY() { + List lines = layoutCache.getVisualLines(); + if (lines.isEmpty()) return PADDING; + int lineIdx = getVisualLineIndex(selectionModel.getCursorIndex()); + return PADDING + lineIdx * getLineHeight() - visualVerticalOffsetPixels.rounded(); + } + + public boolean isCursorVisibleInViewport() { + int cursorX = getCursorPixelX(); + int cursorY = getCursorPixelY(); + return cursorX >= PADDING && cursorX <= getContentClipWidth() - PADDING + && cursorY >= PADDING + && cursorY <= getContentClipHeight() - PADDING; + } + private int getCursorIndexAt(int mouseX, int mouseY) { List lines = layoutCache.getVisualLines(); if (lines.isEmpty()) { return 0; } - int localY = mouseY - y - PADDING + scrollState.getOffsetPixels(); + int localY = mouseY - y - PADDING + visualVerticalOffsetPixels.rounded(); int lineIndex = localY <= 0 ? 0 : localY / getLineHeight(); if (lineIndex < 0) { lineIndex = 0; @@ -938,7 +1303,7 @@ private int getCursorIndexAt(int mouseX, int mouseY) { if (lineIndex >= lines.size()) { lineIndex = lines.size() - 1; } - int localX = Math.max(0, mouseX - x - PADDING + horizontalOffsetPixels); + int localX = Math.max(0, mouseX - x - PADDING + visualHorizontalOffsetPixels.rounded()); return getCursorIndexAtPixel(lines.get(lineIndex), localX); } @@ -1026,6 +1391,22 @@ private int clampHorizontalOffset(int requestedOffset) { return Math.min(requestedOffset, maxOffset); } + private void snapVisualOffsetsToTarget() { + visualVerticalOffsetPixels.snapTo(scrollState.getOffsetPixels()); + visualHorizontalOffsetPixels.snapTo(horizontalOffsetPixels); + } + + private void updateVisualOffsets() { + visualVerticalOffsetPixels.updateTowards( + scrollState.getOffsetPixels(), + 30f, + 0.25f, + 0.01f, + Math.max(160f, getContentClipHeight() * 2f)); + visualHorizontalOffsetPixels + .updateTowards(horizontalOffsetPixels, 30f, 0.25f, 0.01f, Math.max(160f, textViewportWidth * 2f)); + } + private int clamp(int value, int min, int max) { if (value < min) { return min; @@ -1062,6 +1443,15 @@ public static boolean isCtrlKeyCombo(int keyCode, int expectedKeyCode) { && (Keyboard.isKeyDown(Keyboard.KEY_LCONTROL) || Keyboard.isKeyDown(Keyboard.KEY_RCONTROL)); } + public void setDoubleClickHandler(@Nullable DoubleClickHandler handler) { + this.doubleClickHandler = handler; + } + + public interface DoubleClickHandler { + + void onDoubleClick(int cursorIndex); + } + public interface ClipboardAccess { void copy(String text); diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorAction.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorAction.java index 79ec91d6..bf4d9fc1 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorAction.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorAction.java @@ -127,6 +127,7 @@ public enum GuideScreenEditorAction { COPY(GuidebookText.GuideEditorCopy), PASTE(GuidebookText.GuideEditorPaste), SELECT_ALL(GuidebookText.GuideEditorSelectAll), + FORMAT_DOCUMENT(GuidebookText.GuideEditorFormatDocument), TOGGLE_ADVANCED(GuidebookText.GuideEditorAdvancedToggle); private final GuidebookText tooltipKey; diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorContextMenu.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorContextMenu.java index f40e8864..88bb281b 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorContextMenu.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorContextMenu.java @@ -14,6 +14,7 @@ import com.hfstudio.guidenh.guide.internal.editor.gui.SceneEditorPopupLayout; import com.hfstudio.guidenh.guide.internal.screen.GuideIconButton; import com.hfstudio.guidenh.guide.internal.util.DisplayScale; +import com.hfstudio.guidenh.guide.internal.util.SmoothFloatState; public class GuideScreenEditorContextMenu { @@ -116,6 +117,8 @@ public void open(int mouseX, int mouseY, int viewportWidth, int viewportHeight, panes.add(new MenuPane(entries, rootRect.x(), rootRect.y(), rootWidth, rootHeight)); open = true; draggingScrollbarPaneIndex = -1; + panes.getFirst() + .snapVisualScrollToTarget(); update(mouseX, mouseY, viewportWidth, viewportHeight, fontRenderer); } @@ -127,6 +130,7 @@ public void setViewport(int viewportWidth, int viewportHeight, FontRenderer font rootPane.width = computeMenuWidth(entries, fontRenderer); rootPane.height = clampMenuHeight(computeMenuContentHeight(entries), viewportHeight); rootPane.scrollY = clampScroll(rootPane.scrollY, rootPane.entries, rootPane.height); + rootPane.snapVisualScrollToTarget(); var rootRect = SceneEditorPopupLayout .clampToViewport(rootPane.x, rootPane.y, rootPane.width, rootPane.height, viewportWidth, viewportHeight, 2); rootPane.x = rootRect.x(); @@ -205,6 +209,7 @@ public void update(int mouseX, int mouseY, int viewportWidth, int viewportHeight } for (MenuPane pane : panes) { pane.scrollY = clampScroll(pane.scrollY, pane.entries, pane.height); + pane.updateVisualScroll(); } int paneIndex = findDeepestPaneIndex(mouseX, mouseY); @@ -223,7 +228,7 @@ public void update(int mouseX, int mouseY, int viewportWidth, int viewportHeight pane.y, pane.width, pane.height, - pane.scrollY, + pane.visualScrollY.rounded(), pane.entries); if (entryIndex < 0) { pane.hoveredIndex = -1; @@ -257,7 +262,7 @@ private void ensureChildPane(int paneIndex, int entryIndex, int viewportWidth, i childX = parentPane.x - childWidth + 1; } childX = clampToViewportX(childX, childWidth, viewportWidth); - int childY = parentPane.y + PADDING_Y + entryIndex * ITEM_HEIGHT - parentPane.scrollY; + int childY = parentPane.y + PADDING_Y + entryIndex * ITEM_HEIGHT - parentPane.visualScrollY.rounded(); childY = clampToViewportY(childY, childHeight, viewportHeight); int childPaneIndex = paneIndex + 1; @@ -267,6 +272,7 @@ private void ensureChildPane(int paneIndex, int entryIndex, int viewportWidth, i childPane.entries = childEntries; childPane.hoveredIndex = -1; childPane.scrollY = 0; + childPane.snapVisualScrollToTarget(); } childPane.x = childX; childPane.y = childY; @@ -274,7 +280,9 @@ private void ensureChildPane(int paneIndex, int entryIndex, int viewportWidth, i childPane.height = childHeight; childPane.scrollY = clampScroll(childPane.scrollY, childPane.entries, childPane.height); } else { - panes.add(new MenuPane(childEntries, childX, childY, childWidth, childHeight)); + MenuPane pane = new MenuPane(childEntries, childX, childY, childWidth, childHeight); + pane.snapVisualScrollToTarget(); + panes.add(pane); } } @@ -344,7 +352,7 @@ public void draw(FontRenderer fontRenderer, int mouseX, int mouseY) { pane.height, pane.entries, pane.hoveredIndex, - pane.scrollY); + pane.visualScrollY.rounded()); } } @@ -475,8 +483,11 @@ private boolean startScrollbarDrag(int mouseX, int mouseY) { pane.entries, pane.scrollY)) { draggingScrollbarPaneIndex = i; - scrollbarGrabOffset = mouseY - - scrollbarThumbY(pane.y, pane.height, computeMenuContentHeight(pane.entries), pane.scrollY); + scrollbarGrabOffset = mouseY - scrollbarThumbY( + pane.y, + pane.height, + computeMenuContentHeight(pane.entries), + pane.visualScrollY.rounded()); return true; } } @@ -552,6 +563,7 @@ private static final class MenuPane { private int width; private int height; private int scrollY; + private final SmoothFloatState visualScrollY = new SmoothFloatState(); private int hoveredIndex = -1; private MenuPane(List entries, int x, int y, int width, int height) { @@ -561,5 +573,13 @@ private MenuPane(List entries, int x, int y, int width, int height) { this.width = width; this.height = height; } + + private void snapVisualScrollToTarget() { + visualScrollY.snapTo(scrollY); + } + + private void updateVisualScroll() { + visualScrollY.updateTowards(scrollY, 28f, 0.25f, 0.01f, Math.max(96f, height * 2f)); + } } } diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorSourceState.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorSourceState.java new file mode 100644 index 00000000..8257e5c7 --- /dev/null +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorSourceState.java @@ -0,0 +1,22 @@ +package com.hfstudio.guidenh.guide.internal.editor.guide; + +import com.hfstudio.guidenh.guide.internal.util.GuideStringLines; + +public class GuideScreenEditorSourceState { + + private GuideScreenEditorSourceState() {} + + public static String normalizeEditorText(String text) { + if (text == null || text.isEmpty()) { + return ""; + } + return GuideStringLines.normalizeLineEndings(text); + } + + public static boolean isDirty(String draftSource, String savedSource) { + if (draftSource != null && draftSource.equals(savedSource)) { + return false; + } + return !normalizeEditorText(draftSource).equals(normalizeEditorText(savedSource)); + } +} diff --git a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorTextActions.java b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorTextActions.java index 77a90bf4..af0ece6a 100644 --- a/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorTextActions.java +++ b/src/main/java/com/hfstudio/guidenh/guide/internal/editor/guide/GuideScreenEditorTextActions.java @@ -4,7 +4,7 @@ public class GuideScreenEditorTextActions { private GuideScreenEditorTextActions() {} - public static final class Result { + public static class Result { private final String text; private final int selectionStart; @@ -316,6 +316,78 @@ public static Result apply(GuideScreenEditorAction action, String text, int sele }; } + public static String formatDocument(String text) { + String source = normalizeLineEndings(text); + String[] lines = source.split("\n", -1); + StringBuilder formatted = new StringBuilder(source.length()); + int mdxIndent = 0; + boolean previousBlank = false; + for (int i = 0; i < lines.length; i++) { + String rawLine = trimRight(lines[i]); + String trimmed = rawLine.trim(); + if (trimmed.isEmpty()) { + if (!previousBlank && i + 1 < lines.length) { + formatted.append('\n'); + } + previousBlank = true; + continue; + } + previousBlank = false; + if (isMdxClosingLine(trimmed)) { + mdxIndent = Math.max(0, mdxIndent - 1); + } + if (shouldIndentLine(trimmed)) { + appendSpaces(formatted, mdxIndent * 4); + } + formatted.append(trimmed) + .append('\n'); + if (opensMdxBlock(trimmed)) { + mdxIndent++; + } + } + if (formatted.length() > 0 && formatted.charAt(formatted.length() - 1) == '\n' && !source.endsWith("\n")) { + formatted.setLength(formatted.length() - 1); + } + return formatted.toString(); + } + + private static boolean shouldIndentLine(String trimmed) { + return trimmed.startsWith("<") && !trimmed.startsWith("