diff --git a/docs/en/04-extension-components.md b/docs/en/04-extension-components.md index beab3bb..6aa188a 100644 --- a/docs/en/04-extension-components.md +++ b/docs/en/04-extension-components.md @@ -374,6 +374,8 @@ This is a danger box (red). ### Row Component — `` / `::: row` +Horizontal rows wrap whole components to the available width, keeping recipe proportions and applying alignment per line. Height and hit areas follow the wrapped layout, so guide zoom does not hide recipes on the right. + Renders child components side by side in a horizontal layout. **Smart Width Allocation:** @@ -462,11 +464,17 @@ Blocks without a corresponding item (technical blocks) will not render. > **Note:** Unlike ``, `` looks up the **block** registry and automatically finds the > matching block item. You do not need to know the block item's ID separately. +### Key Binding — `` + +`` shows the currently bound key in paragraphs and lists. Rebinding updates the displayed name without editing the document. + ### Entity Display — `` Renders an entity preview with `InventoryScreen.renderEntityInInventoryFollowsMouse`. The entity rotates to follow the cursor. +The entity follows its row and guide transforms and is clipped to its preview frame. Its size is measured before the first layout to keep following content in place. + ```markdown diff --git a/docs/en/10-structure-preview-rendering.md b/docs/en/10-structure-preview-rendering.md index 8204875..3aef7d6 100644 --- a/docs/en/10-structure-preview-rendering.md +++ b/docs/en/10-structure-preview-rendering.md @@ -98,4 +98,5 @@ Left-click a block in a structure or machine preview to show its localized name, - Left-click to inspect the block name, registry ID, structure coordinates and block state. The hover hint is simply “Click to inspect”. - Right-click a block with a menu to open its native read-only GUI. Its saved contents, mode, timing and progress are shown by the native menu. Player inventory and hotbar slots stay empty. No menu input or close/configuration packets are sent. - Hold the left button to drag the popup; right-click it to close. A white connector ends at the popup edge and points to the block center. Backgrounds, text and items fade together; `reduced_motion` reduces scaling. -- Drag the structure with the left/right button to pan/rotate. Ctrl + scroll to zoom. Layer buttons and PageUp / PageDown change visible layers. +- Drag the structure with the left/right button to pan/rotate. Scroll to zoom. Layer buttons and PageUp / PageDown change visible layers. +- A wheel gesture stays with the region where it starts: outside a structure it scrolls the page, inside it zooms that same structure. Crossing regions or moving a structure under the pointer does not change the target. At least 0.5 seconds without wheel input releases it; reversing direction does not. diff --git a/docs/zh/04-extension-components.md b/docs/zh/04-extension-components.md index 0d10778..8afed02 100644 --- a/docs/zh/04-extension-components.md +++ b/docs/zh/04-extension-components.md @@ -367,6 +367,8 @@ Ageratum 自带若干内置扩展组件,可在任意文档中直接使用。 ### 行组件 — `` / `::: row` +横向排列会按当前可用宽度自动换行,整项配方移到下一行后仍保留自身比例;每行独立应用水平和垂直对齐。行高与交互命中随换行同步更新,手册缩放不会丢失右侧配方。 + 将内部的子组件以水平方式(并排)渲染。 **智能宽度分配:** @@ -455,10 +457,16 @@ Ageratum 自带若干内置扩展组件,可在任意文档中直接使用。 > **注意:** 与 `` 不同,`` 查询的是**方块**注册表,自动匹配对应的方块物品。 > 你不需要知道方块物品的独立 ID。 +### 按键名称 — `` + +`` 显示当前客户端绑定的按键名称,可用于段落和列表;修改按键绑定后会同步更新,无需修改手册文本。 + ### 实体展示 — `` 使用 `InventoryScreen.renderEntityInInventoryFollowsMouse` 渲染一个实体预览,实体会随鼠标移动而转向。 +实体会按所在行和手册缩放定位,并裁剪在预览框内;首次布局前即测量实体尺寸,避免打开页面时模型和后续段落错位。 + ```markdown diff --git a/docs/zh/10-structure-preview-rendering.md b/docs/zh/10-structure-preview-rendering.md index 951f8a7..b3c6903 100644 --- a/docs/zh/10-structure-preview-rendering.md +++ b/docs/zh/10-structure-preview-rendering.md @@ -98,4 +98,5 @@ - 左键单击查看方块名称、注册 ID、结构内坐标和方块状态。悬停提示简写为“点击查看”。 - 右键单击有菜单的方块打开原生只读 GUI 浮窗,保存的库存、模式、延时和进度由对应菜单显示,玩家物品栏与快捷栏固定为空。浮窗不接收菜单操作,也不会发送关闭或修改配置的数据包。 - 按住左键拖动浮窗,右键关闭。浮窗以白线连接到方块中心,线段在窗口边缘结束;背景、文字和物品同步淡入淡出。配置 `reduced_motion` 可减少缩放动画。 -- 左键拖动结构进行平移,右键拖动旋转;Ctrl+滚轮缩放。层数按钮与 PageUp / PageDown 调整显示层数。 +- 左键拖动结构进行平移,右键拖动旋转;滚轮缩放。层数按钮与 PageUp / PageDown 调整显示层数。 +- 连续滚轮操作按起始位置锁定:从结构外开始时持续滚动页面,从结构内开始时持续缩放同一个结构;即使鼠标跨区域或滚动使结构移到鼠标下,也不会中途切换。停滚至少 0.5 秒后重新判断,反向滚动不会结束当前操作。 diff --git a/gradle/scripts/structure-test.gradle b/gradle/scripts/structure-test.gradle index 7619945..15aac68 100644 --- a/gradle/scripts/structure-test.gradle +++ b/gradle/scripts/structure-test.gradle @@ -12,6 +12,7 @@ neoForge { runs { structureTest { client() + systemProperty 'ageratum.scrollEntityTest', providers.gradleProperty('scrollEntityTest').isPresent().toString() systemProperty 'ageratum.structureGuiReview', providers.gradleProperty('structureGuiReview').isPresent().toString() systemProperty 'ageratum.structureGuiTest', providers.gradleProperty('structureGuiTest').isPresent().toString() systemProperty 'ageratum.blockDetailsTest', providers.gradleProperty('blockDetailsTest').isPresent().toString() diff --git a/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/ListRecipeClientScreen.java b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/ListRecipeClientScreen.java new file mode 100644 index 0000000..c615ba4 --- /dev/null +++ b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/ListRecipeClientScreen.java @@ -0,0 +1,87 @@ +package dev.anvilcraft.resource.ageratum.test; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.MDRenderContext; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDComponent; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDListComponent; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.extend.MDRowComponent; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; + +import java.util.ArrayList; +import java.util.List; + +/** 检查列表首行对齐、实时按键绑定,以及实际配方的宽窄排版。 */ +final class ListRecipeClientScreen extends Screen { + private final ListProbe list; + private final MDRowComponent recipes; + + ListRecipeClientScreen() { + super(Component.literal("Lists and wrapped recipes")); + Minecraft mc = Minecraft.getInstance(); + var keyList = new MDListComponent(List.of(MDListComponent.unordered(0, "按住 操作"))); + var key = mc.options.keyShift; + var original = key.getKey(); + try { + key.setKey(InputConstants.UNKNOWN); + ScrollEntityClientTest.check(keyList.getText().getString().equals("按住 " + key.getTranslatedKeyMessage().getString() + " 操作"), + "inline key resolves inside lists and follows rebinding"); + } finally { + key.setKey(original); + } + this.list = new ListProbe(List.of( + MDListComponent.unordered(0, "圆点与首行对齐,长文本自动换行后仍然保持统一的正文左边缘,不再显示灰色背景。"), + MDListComponent.unordered(1, "嵌套列表保留层级缩进。"), + MDListComponent.ordered(0, 9, "编号在标记列内右对齐。"), + MDListComponent.ordered(0, 10, "两位和三位编号都不会碰到正文,也不会挤压数字。"), + MDListComponent.ordered(0, 100, "更长的编号仍保持同一文字起点。"), + MDListComponent.task(0, false, "未完成任务"), + MDListComponent.task(0, true, "已完成任务"), + MDListComponent.unordered(0, "按住 操作"))); + var simple = new ListProbe(List.of(MDListComponent.unordered(0, "parent"), MDListComponent.unordered(1, "child"))); + ScrollEntityClientTest.check(this.list.textX(1) - this.list.textX(0) >= simple.textX(1) - simple.textX(0), + "wide parent numbering preserves nested indentation"); + ScrollEntityClientTest.check(this.list.textX(2) == this.list.textX(3) && this.list.textX(3) == this.list.textX(4), + "ordered entries share their text origin"); + List items = new ArrayList<>(); + for (String name : List.of("crafting_table", "chest", "furnace", "hopper")) { + ResourceLocation id = ResourceLocation.parse("minecraft:" + name); + var recipe = mc.getSingleplayerServer().getRecipeManager().byKey(id).orElseThrow(); + var component = new dev.anvilcraft.resource.ageratum.client.feat.markdown.component.extend.recipe.MDCraftingTableRecipeComponent( + (net.minecraft.world.item.crafting.CraftingRecipe) recipe.value(), false); + items.add(component); + } + this.recipes = new MDRowComponent(items, MDRowComponent.Direction.HORIZONTAL, + MDRowComponent.HorizontalAlign.LEFT, MDRowComponent.VerticalAlign.TOP); + ScrollEntityClientTest.check(this.recipes.getHeight(mc, 150, Integer.MAX_VALUE) + > this.recipes.getHeight(mc, 440, Integer.MAX_VALUE), "recipes gain lines in a narrower viewport"); + } + + private static final class ListProbe extends MDListComponent { + private ListProbe(List items) { super(items); } + private int textX(int index) { return this.getTextX(this.cachedItems.get(index)); } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, this.width, this.height, 0xFFF6EDD5); + this.draw(graphics, this.list, 20, 20, 190, 1.5f); + this.draw(graphics, this.recipes, 340, 20, 440, 1); + this.draw(graphics, this.recipes, 340, 180, 150, 1); + } + + private void draw(GuiGraphics graphics, MDComponent component, int x, int y, int width, float scale) { + graphics.pose().pushPose(); + graphics.pose().translate(x, y, 0); + graphics.pose().scale(scale, scale, 1); + component.render(new MDRenderContext(null, Minecraft.getInstance(), graphics, new ArrayList<>(), + this.width, this.height, width, 1000, -10000, -10000, Math.round(x / scale), Math.round(y / scale), 1 / scale, + 0, 0, new ArrayList<>())); + graphics.pose().popPose(); + } +} diff --git a/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/ScrollEntityClientTest.java b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/ScrollEntityClientTest.java new file mode 100644 index 0000000..9fac388 --- /dev/null +++ b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/ScrollEntityClientTest.java @@ -0,0 +1,133 @@ +package dev.anvilcraft.resource.ageratum.test; + +import dev.anvilcraft.resource.ageratum.client.feat.markdown.MDDocument; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.MDRenderContext; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDComponent; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.extend.MDEntityComponent; +import dev.anvilcraft.resource.ageratum.client.gui.GuideScreen; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** 在真实手册上验证滚轮目标,并截取不同 GUI 变换下的实体预览。 */ +public final class ScrollEntityClientTest { + private static int stage; + private static long due; + private static ScrollScreen guide; + + static boolean tick(Minecraft mc) { + if (System.nanoTime() < due) return false; + switch (stage++) { + case 0 -> { + dev.anvilcraft.resource.ageratum.client.AgeratumClient.CONFIG.scale = 1; + mc.options.guiScale().set(1); + mc.resizeDisplay(); + guide = new ScrollScreen(); + mc.setScreen(guide); + } + case 1 -> guide.pageBurst(); + case 2 -> guide.structureBurst(); + case 3 -> { + guide.pageAfterPause(); + mc.setScreen(new EntityScreen()); + } + case 4 -> { + Screenshot.grab(mc.gameDirectory, "entity-scroll-regression.png", mc.getMainRenderTarget(), message -> {}); + mc.setScreen(new ListRecipeClientScreen()); + } + default -> { + Screenshot.grab(mc.gameDirectory, "list-recipe-regression.png", mc.getMainRenderTarget(), message -> {}); + return true; + } + } + due = System.nanoTime() + 650_000_000; + return false; + } + + static void check(boolean value, String message) { + if (!value) throw new AssertionError(message); + } + + private static final class ScrollScreen extends GuideScreen { + private final ScrollBlock structure; + private ScrollScreen() { this(new ScrollBlock()); } + private ScrollScreen(ScrollBlock structure) { + super(ResourceLocation.parse("ageratum:scroll-test"), new MDDocument(null, Map.of(), + List.of(new Space(40), structure, new Space(800))), List.of(), false); + this.structure = structure; + } + private boolean wheel(double localY, double delta) { + return this.wheelAt(this.getContentWidth() / 2.0, localY, delta); + } + private boolean wheelAt(double localX, double localY, double delta) { + return this.mouseScrolled((this.leftPos + this.getContentStartX() + localX) / this.scale, + (this.topPos + this.getContentStartY() + localY) / this.scale, 0, delta); + } + private void pageBurst() { + this.contentScroll = 0; + check(this.wheelAt(-1, 5, -1), "content left edge wins over overlapping sidebar labels"); + float first = this.contentScroll; + check(first > 0, "page moved; maximum=" + this.maxContentScroll + ", components=" + this.parsedComponents.size()); + check(this.wheel(70 - first, -1), "wheel over structure remains captured by page"); + check(this.contentScroll > first && this.structure.delta == 0, "crossing structure does not interrupt page"); + } + private void structureBurst() { + this.contentScroll = 0; + check(this.wheel(70, -1), "structure accepts first wheel after pause"); + check(this.structure.delta == -1, "structure zooms"); + check(this.mouseScrolled(0, 0, 0, 1), "structure retains wheel outside page"); + check(this.structure.delta == 0 && this.contentScroll == 0, "reversal stays with structure"); + } + private void pageAfterPause() { + this.contentScroll = 70; + check(this.wheel(-1, -1), "paper top margin starts page gesture after idle"); + check(this.contentScroll > 70 && this.structure.delta == 0, "clipped structure cannot capture paper margin"); + } + } + + private static class Space extends MDComponent { + private final int height; + private Space(int height) { super(""); this.height = height; } + @Override public int getHeight(Minecraft minecraft, int maxX, int maxY) { return this.height; } + } + + private static final class ScrollBlock extends Space { + private double delta; + private ScrollBlock() { super(90); } + @Override public boolean mouseScrolled(Minecraft minecraft, double x, double y, double delta, int maxX) { + this.delta += delta; + return true; + } + } + + private static final class EntityScreen extends Screen { + private final MDEntityComponent villager = new MDEntityComponent(ResourceLocation.parse("minecraft:villager"), new CompoundTag(), false); + private final MDEntityComponent trader = new MDEntityComponent(ResourceLocation.parse("minecraft:wandering_trader"), new CompoundTag(), false); + private EntityScreen() { super(Component.literal("Entity preview regression")); } + @Override public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + graphics.fill(0, 0, this.width, this.height, 0xFFF6EDD5); + this.preview(graphics, this.villager, 40, 35, 1); + this.preview(graphics, this.trader, 240, 35, 1.5f); + this.preview(graphics, this.villager, 440, 35, 0.75f); + } + private void preview(GuiGraphics graphics, MDEntityComponent entity, int x, int y, float scale) { + int height = entity.getHeight(Minecraft.getInstance(), 128, 200); + check(height >= 72, "entity size is available before its first render"); + graphics.pose().pushPose(); + graphics.pose().translate(x, y, 0); + graphics.pose().scale(scale, scale, 1); + entity.render(new MDRenderContext(null, Minecraft.getInstance(), graphics, new ArrayList<>(), + this.width, this.height, 128, 200, 64, 36, Math.round(x / scale), Math.round(y / scale), 1 / scale, + 0, 0, new ArrayList<>())); + graphics.pose().popPose(); + } + } +} diff --git a/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java index fcd00c8..423c2b4 100644 --- a/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java +++ b/src/clientTest/java/dev/anvilcraft/resource/ageratum/test/StructureTest.java @@ -53,7 +53,11 @@ public final class StructureTest { public StructureTest(IEventBus bus) { bus.addListener((ModelEvent.BakingCompleted event) -> this.ready = true); NeoForge.EVENT_BUS.addListener(this::tick); - NeoForge.EVENT_BUS.addListener(this::render); + if (Boolean.getBoolean("ageratum.scrollEntityTest")) { + NeoForge.EVENT_BUS.addListener((net.neoforged.neoforge.client.event.RenderFrameEvent.Post event) -> this.scrollFrame()); + } else { + NeoForge.EVENT_BUS.addListener(this::render); + } } private void tick(ClientTickEvent.Post event) { @@ -77,6 +81,20 @@ private void tick(ClientTickEvent.Post event) { .value().createWorldDimensions(), mc.screen); } + private void scrollFrame() { + Minecraft mc = Minecraft.getInstance(); + if (this.stage == 5 || mc.level == null || mc.player == null || mc.getOverlay() != null) return; + try { + if (ScrollEntityClientTest.tick(mc)) { + LogUtils.getLogger().info("AGERATUM_STRUCTURE_TEST PASS: scroll ownership, idle release and entity viewport"); + this.stage = 5; + mc.stop(); + } + } catch (Throwable exception) { + this.fail(exception); + } + } + private void render(RenderLevelStageEvent event) { Minecraft mc = Minecraft.getInstance(); if (event.getStage() != RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS || mc.level == null diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDComponent.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDComponent.java index da061f3..9b54669 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDComponent.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDComponent.java @@ -160,6 +160,12 @@ public boolean mouseScrolled(Minecraft minecraft, double mouseX, double mouseY, return false; } + /** 固定滚动起点的组件及局部坐标,供连续滚轮操作复用。 */ + public java.util.function.DoublePredicate scrollHandler(Minecraft minecraft, double mouseX, double mouseY, int maxX) { + return delta -> this.mouseScrolled(minecraft, mouseX, mouseY, delta, maxX); + } + + /** * 处理组件内部鼠标按下事件。 * diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDListComponent.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDListComponent.java index 9c1e32a..dd311d9 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDListComponent.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/MDListComponent.java @@ -13,25 +13,13 @@ /** * Markdown 列表组件。 * - *

统一渲染无序列表、有序列表与任务列表,并支持按缩进层级绘制视觉引导带。

+ *

统一渲染无序列表、有序列表与任务列表,按首行对齐标记并为多位编号预留统一宽度。

*/ public class MDListComponent extends MDBlockComponent { - private static final String[] BULLETS = { - "●", - "○", - "■", - "□", - "◆", - "◇", - "▸", - "▹" - }; - private static final String TASK_UNCHECKED = "☐"; - private static final String TASK_CHECKED = "☑"; - private static final int INDENT_WIDTH = 10; - private static final int MARKER_WIDTH = 12; - private static final int TASK_UNCHECKED_COLOR = 0x666666; - private static final int TASK_CHECKED_COLOR = 0x2E7D32; + private static final int INDENT_WIDTH = 12; + private static final int MIN_MARKER_WIDTH = 8; + private static final int MARKER_GAP = 5; + private final java.util.Map orderedDigits = new java.util.HashMap<>(); /** * 使用解析后的列表项创建组件。 @@ -42,6 +30,11 @@ public MDListComponent(List items) { private MDListComponent(PreparedData preparedData) { super(preparedData.componentText(), preparedData.cachedItems()); + for (CachedItem cached : this.cachedItems) { + if (cached.item().kind() == ListKind.ORDERED) { + this.orderedDigits.merge(cached.level(), Integer.toString(cached.item().index()).length(), Math::max); + } + } } /** @@ -71,7 +64,11 @@ public static ListItem task(int level, boolean checked, String text) { @Override protected int getTextX(CachedItem cachedItem) { - return cachedItem.level() * INDENT_WIDTH + MARKER_WIDTH; + int x = this.markerWidth(0) + MARKER_GAP; + for (int level = 1; level <= cachedItem.level(); level++) { + x += Math.max(INDENT_WIDTH, this.markerWidth(level) + MARKER_GAP); + } + return x; } @Override @@ -83,27 +80,49 @@ protected void renderDecoration( int lineHeight, int maxX ) { - int lineBottom = y + lineHeight; - for (int level = 0; level <= cachedItem.level(); level++) { - int bandStartX = level * INDENT_WIDTH; - guiGraphics.fill( - bandStartX, - y, - bandStartX + INDENT_WIDTH - 1, - lineBottom, - LEVEL_LINE_COLORS[level % LEVEL_LINE_COLORS.length] | 0x55000000 - ); + ListItem item = cachedItem.item(); + int right = this.getTextX(cachedItem) - MARKER_GAP; + int color = GuideTheme.textColor(); + if (item.kind() == ListKind.ORDERED) { + String text = item.index() + "."; + guiGraphics.drawString(minecraft.font, text, right - minecraft.font.width(text), y, color, false); + return; + } + int centerX = right - this.markerWidth(cachedItem.level()) / 2; + int centerY = y + minecraft.font.lineHeight / 2; + if (item.kind() == ListKind.TASK) { + guiGraphics.renderOutline(centerX - 3, centerY - 3, 7, 7, color); + if (item.checked()) { + guiGraphics.fill(centerX - 2, centerY, centerX, centerY + 1, color); + guiGraphics.fill(centerX - 1, centerY + 1, centerX + 1, centerY + 2, color); + guiGraphics.fill(centerX, centerY, centerX + 2, centerY + 1, color); + guiGraphics.fill(centerX + 1, centerY - 1, centerX + 3, centerY, color); + } + return; + } + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate(centerX + 0.5f, centerY + 0.5f, 0); + guiGraphics.pose().scale(0.5f, 0.5f, 1); + for (int row = -3; row < 3; row++) { + int halfWidth = 3 - (row < 0 ? -row - 1 : row); + if (item.level() % 2 == 0 || halfWidth == 1) { + guiGraphics.fill(-halfWidth, row, halfWidth, row + 1, color); + } else { + guiGraphics.fill(-halfWidth, row, -halfWidth + 1, row + 1, color); + guiGraphics.fill(halfWidth - 1, row, halfWidth, row + 1, color); + } } + guiGraphics.pose().popPose(); + } - ListItem item = cachedItem.item(); - guiGraphics.drawString( - minecraft.font, - marker(item), - cachedItem.level() * INDENT_WIDTH, - y, - markerColor(item), - false - ); + private int markerWidth(int level) { + int digits = this.orderedDigits.getOrDefault(level, 0); + if (digits == 0) return MIN_MARKER_WIDTH; + int digitWidth = 0; + for (char digit = '0'; digit <= '9'; digit++) { + digitWidth = Math.max(digitWidth, Minecraft.getInstance().font.width(String.valueOf(digit))); + } + return Math.max(MIN_MARKER_WIDTH, digits * digitWidth + Minecraft.getInstance().font.width(".")); } private static PreparedData prepare(List sourceItems) { @@ -124,21 +143,6 @@ private static PreparedData prepare(List sourceItems) { return new PreparedData(List.copyOf(cachedItems), composeBlockText(cachedItems)); } - private static String marker(ListItem item) { - return switch (item.kind()) { - case UNORDERED -> BULLETS[item.level() % BULLETS.length]; - case ORDERED -> item.index() + "."; - case TASK -> item.checked() ? TASK_CHECKED : TASK_UNCHECKED; - }; - } - - private static int markerColor(ListItem item) { - if (item.kind() == ListKind.TASK) { - return item.checked() ? TASK_CHECKED_COLOR : TASK_UNCHECKED_COLOR; - } - return GuideTheme.textColor(); - } - private record PreparedData(List> cachedItems, FormattedText componentText) { } diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDEntityComponent.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDEntityComponent.java index af05420..f73776e 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDEntityComponent.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDEntityComponent.java @@ -101,7 +101,7 @@ private float getScale(Entity entity, float contextScale) { @Override public int getPreferredWidth(Minecraft minecraft, int maxX, int maxY) { - Entity entity = this.cachedEntity; + Entity entity = this.getEntity(); if (entity == null) return super.getPreferredWidth(minecraft, maxX, maxY); int textWidth = this.showText ? minecraft.font.width(entity.getName()) : 0; return Math.max(this.getContentWidth(maxX), textWidth); @@ -109,7 +109,7 @@ public int getPreferredWidth(Minecraft minecraft, int maxX, int maxY) { @Override public int getHeight(Minecraft minecraft, int maxX, int maxY) { - Entity entity = this.cachedEntity; + Entity entity = this.getEntity(); if (entity == null) return super.getHeight(minecraft, maxX, maxY); int textHeight = this.showText ? minecraft.font.lineHeight : 0; return this.getContentHeight() + textHeight + 2; diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java index 0135a28..92351ad 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDNBTStructureComponent.java @@ -282,7 +282,7 @@ public boolean blocksParentKeyHandling( @Override public boolean mouseScrolled(Minecraft minecraft, double mouseX, double mouseY, double scrollY, int maxX) { - if (!Screen.hasControlDown() || scrollY == 0.0d) { + if (scrollY == 0.0d) { return false; } diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDRowComponent.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDRowComponent.java index 3fd58a0..6f92e16 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDRowComponent.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/feat/markdown/component/extend/MDRowComponent.java @@ -273,7 +273,7 @@ private WrappedLayout computeWrappedLayout(Minecraft minecraft, int maxX) { int currentWidth = 0; int currentHeight = 0; for (int i = 0; i < preferredWidths.length; i++) { - int width = preferredWidths[i]; + int width = Math.min(Math.max(1, maxX), preferredWidths[i]); int height = this.contentComponents.get(i).getHeight(minecraft, width, Integer.MAX_VALUE); int addWidth = currentChildren.isEmpty() ? width : currentWidth + SPACING + width; if (!currentChildren.isEmpty() && addWidth > maxX) { @@ -323,6 +323,13 @@ public boolean mouseScrolled(Minecraft minecraft, double mouseX, double mouseY, ); } + @Override + public java.util.function.DoublePredicate scrollHandler(Minecraft minecraft, double mouseX, double mouseY, int maxX) { + ChildHit hit = this.hitTest(minecraft, mouseX, mouseY, maxX); + return hit == null ? delta -> false + : hit.component.scrollHandler(minecraft, mouseX - hit.x, mouseY - hit.y, hit.width); + } + @Override public boolean mouseClicked(Minecraft minecraft, double mouseX, double mouseY, int button, int maxX) { ChildHit hit = this.hitTest(minecraft, mouseX, mouseY, maxX); diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScreen.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScreen.java index b03b178..d5a0622 100644 --- a/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScreen.java +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScreen.java @@ -141,6 +141,7 @@ public class GuideScreen extends Screen { protected float contentScroll; protected double lastMouseX; protected double lastMouseY; + private final GuideScrollGesture scrollGesture = new GuideScrollGesture(); protected @Nullable MDComponent activeMouseComponent; protected int activeMouseButton = -1; /** @@ -293,6 +294,7 @@ private void reloadPreviewDocumentAtPreviousPosition() { } float previousScroll = this.contentScroll; + this.scrollGesture.reset(); this.parsedComponents.clear(); this.parsedComponents.addAll(this.parser.parseDocument(this.documentLocation, markdown).components()); this.recordPreviewDocumentFingerprint(); @@ -486,58 +488,47 @@ public void renderTransparentBackground(GuiGraphics guiGraphics) { */ @Override public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { - if (this.structureGuiPreview.contains(mouseX, mouseY)) return true; - mouseX = mouseX * this.scale; - mouseY = mouseY * this.scale; - if (scrollY == 0.0D) { - return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); - } - if (this.mouseInLabelRange(mouseX, mouseY)) { - // Ctrl/Alt/Shift 加速标签栏翻滚(3倍速度) - double acceleratedScrollY = scrollY; - if (Screen.hasControlDown() || Screen.hasAltDown() || Screen.hasShiftDown()) { - acceleratedScrollY *= 3.0; - } - int rowDelta = this.consumeLabelScrollRows(acceleratedScrollY); - if (rowDelta != 0) { - this.scrollLabelsBy(rowDelta); - } - return true; - } - if (this.mouseInBookmarkRange(mouseX, mouseY)) { - this.bookmarkScrollRemainder -= scrollY; - int rowDelta = (int) Math.copySign(Math.floor(Math.abs(this.bookmarkScrollRemainder) + 0.5d), this.bookmarkScrollRemainder); - if (rowDelta != 0) { - this.bookmarkScrollRemainder -= rowDelta; - this.bookmarkScrollRows = Mth.clamp(this.bookmarkScrollRows + rowDelta, 0, this.maxBookmarkScrollRows); + if (scrollY == 0) return super.mouseScrolled(mouseX * this.scale, mouseY * this.scale, scrollX, scrollY); + long now = System.nanoTime(); + if (this.scrollGesture.continueScroll(scrollY, now)) return true; + if (this.structureGuiPreview.contains(mouseX, mouseY)) { + return this.scrollGesture.begin(scrollY, now, delta -> true); + } + double contentX = mouseX * this.scale, contentY = mouseY * this.scale; + double acceleration = Screen.hasControlDown() || Screen.hasAltDown() || Screen.hasShiftDown() ? 3 : 1; + // 先处理大卷轴内容,避免被伸入卷轴的左侧标签命中区域截获。 + if (this.mouseInPageRange(contentX, contentY)) { + if (this.minecraft != null && this.mouseInContentRange(contentX, contentY)) { + ComponentMouseHit hit = this.getComponentHitAtContentPosition(contentX, contentY); + if (hit != null) { + var handler = hit.component().scrollHandler(this.minecraft, hit.mouseX(), hit.mouseY(), this.getContentWidth()); + if (this.scrollGesture.begin(scrollY, now, delta -> handler.test(delta * acceleration))) return true; + } } - return true; - } - if (!this.mouseInContentRange(mouseX, mouseY)) { - return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); - } - - // Ctrl/Alt/Shift 加速内容区翻滚(3倍速度) - double acceleratedScrollY = scrollY; - if (Screen.hasControlDown() || Screen.hasAltDown() || Screen.hasShiftDown()) { - acceleratedScrollY *= 3.0; + return this.scrollGesture.begin(scrollY, now, delta -> { + this.scrollBy((float) (-delta * acceleration * SCROLL_STEP)); + return true; + }); } - if (this.minecraft != null) { - ComponentMouseHit hit = this.getComponentHitAtContentPosition(mouseX, mouseY); - if (hit != null && hit.component().mouseScrolled( - this.minecraft, - hit.mouseX(), - hit.mouseY(), - acceleratedScrollY, - this.getContentWidth() - )) { + if (this.mouseInLabelRange(contentX, contentY)) { + return this.scrollGesture.begin(scrollY, now, delta -> { + int rows = this.consumeLabelScrollRows(delta * acceleration); + if (rows != 0) this.scrollLabelsBy(rows); return true; - } + }); + } + if (this.mouseInBookmarkRange(contentX, contentY)) { + return this.scrollGesture.begin(scrollY, now, delta -> { + this.bookmarkScrollRemainder -= delta; + int rows = (int) Math.copySign(Math.floor(Math.abs(this.bookmarkScrollRemainder) + 0.5d), this.bookmarkScrollRemainder); + if (rows != 0) { + this.bookmarkScrollRemainder -= rows; + this.bookmarkScrollRows = Mth.clamp(this.bookmarkScrollRows + rows, 0, this.maxBookmarkScrollRows); + } + return true; + }); } - - // scrollY 为正表示向上滚动,故取负以减小 contentScroll(内容上移) - this.scrollBy((float) -acceleratedScrollY * SCROLL_STEP); - return true; + return super.mouseScrolled(contentX, contentY, scrollX, scrollY); } /** @@ -2306,12 +2297,18 @@ private void scrollBy(float delta) { } /** - * 判断鼠标是否位于内容区域(屏幕绝对坐标)。 + * 判断鼠标是否位于卷轴纸面(屏幕绝对坐标)。 * * @param mouseX 鼠标屏幕 X * @param mouseY 鼠标屏幕 Y * @return 在内容区域内返回 {@code true} */ + private boolean mouseInPageRange(double mouseX, double mouseY) { + return mouseX >= this.leftPos && mouseX <= this.leftPos + this.imageWidth + && mouseY >= this.topPos && mouseY <= this.topPos + this.imageHeight; + } + + /** 正文视口的命中区域。 */ private boolean mouseInContentRange(double mouseX, double mouseY) { int contentLeft = this.leftPos + this.getContentStartX(); int contentTop = this.topPos + this.getContentStartY(); @@ -2686,6 +2683,7 @@ public void setAnchor(@Nullable String anchor) { } @Override public void removed() { + this.scrollGesture.reset(); this.structureGuiPreview.clear(); super.removed(); } diff --git a/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScrollGesture.java b/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScrollGesture.java new file mode 100644 index 0000000..14b85ca --- /dev/null +++ b/src/main/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScrollGesture.java @@ -0,0 +1,33 @@ +package dev.anvilcraft.resource.ageratum.client.gui; + +import java.util.function.DoublePredicate; +import javax.annotation.Nullable; + +/** 连续滚轮输入始终交给首次接收它的区域,停顿后才重新命中测试。 */ +public final class GuideScrollGesture { + private static final long IDLE_NANOS = 500_000_000L; + private @Nullable DoublePredicate target; + private long lastScroll; + + public boolean continueScroll(double delta, long now) { + if (delta == 0) return false; + if (this.target == null || now - this.lastScroll >= IDLE_NANOS) { + this.reset(); + return false; + } + this.lastScroll = now; + this.target.test(delta); + return true; + } + + public boolean begin(double delta, long now, DoublePredicate target) { + if (delta == 0 || !target.test(delta)) return false; + this.target = target; + this.lastScroll = now; + return true; + } + + public void reset() { + this.target = null; + } +} diff --git a/src/test/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScrollGestureTest.java b/src/test/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScrollGestureTest.java new file mode 100644 index 0000000..71feddc --- /dev/null +++ b/src/test/java/dev/anvilcraft/resource/ageratum/client/gui/GuideScrollGestureTest.java @@ -0,0 +1,83 @@ +package dev.anvilcraft.resource.ageratum.client.gui; + +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDComponent; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.extend.MDRowComponent; +import net.minecraft.client.Minecraft; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GuideScrollGestureTest { + @Test + void pageScrollKeepsOwnershipUntilTheIdleBoundary() { + var gesture = new GuideScrollGesture(); + var page = new AtomicInteger(); + var structure = new AtomicInteger(); + assertTrue(gesture.begin(-1, 0, delta -> { page.incrementAndGet(); return true; })); + assertTrue(gesture.continueScroll(-1, 400_000_000)); + assertTrue(gesture.continueScroll(1, 899_999_999)); + assertEquals(3, page.get()); + assertFalse(gesture.continueScroll(-1, 1_399_999_999)); + assertTrue(gesture.begin(-1, 1_399_999_999, delta -> { structure.incrementAndGet(); return true; })); + assertTrue(gesture.continueScroll(-1, 1_600_000_000)); + assertEquals(3, page.get()); + assertEquals(2, structure.get()); + } + + @Test + void zeroDeltaDoesNotExtendTheGesture() { + var gesture = new GuideScrollGesture(); + assertTrue(gesture.begin(-0.25, 0, delta -> true)); + assertFalse(gesture.continueScroll(0, 450_000_000)); + assertFalse(gesture.continueScroll(-0.25, 500_000_000)); + assertFalse(gesture.begin(0, 600_000_000, delta -> { throw new AssertionError("zero is not scrolling"); })); + } + + @Test + void rejectedTargetsDoNotCaptureAndResetReleasesTheOldComponent() { + var gesture = new GuideScrollGesture(); + assertFalse(gesture.begin(-1, 0, delta -> false)); + assertFalse(gesture.continueScroll(-1, 100)); + assertTrue(gesture.begin(-1, 100, delta -> true)); + gesture.reset(); + assertFalse(gesture.continueScroll(-1, 200)); + } + + @Test + void nestedRowsKeepTheOriginalStructureWhenThePointerCrossesIntoItsNeighbour() { + var left = new ScrollComponent(); + var right = new ScrollComponent(); + var inner = row(List.of(left, right)); + var outer = row(List.of(inner)); + var gesture = new GuideScrollGesture(); + assertTrue(gesture.begin(-0.5, 0, outer.scrollHandler(null, 10, 5, 84))); + assertTrue(gesture.continueScroll(-0.5, 100_000_000)); + assertEquals(-1, left.delta); + assertEquals(0, right.delta); + assertFalse(gesture.continueScroll(1, 600_000_000)); + assertTrue(gesture.begin(1, 600_000_000, outer.scrollHandler(null, 70, 5, 84))); + assertEquals(-1, left.delta); + assertEquals(1, right.delta); + } + + private static MDRowComponent row(List children) { + return new MDRowComponent(children, MDRowComponent.Direction.HORIZONTAL, + MDRowComponent.HorizontalAlign.LEFT, MDRowComponent.VerticalAlign.TOP); + } + + private static final class ScrollComponent extends MDComponent { + private double delta; + private ScrollComponent() { super(""); } + @Override public int getPreferredWidth(Minecraft minecraft, int maxX, int maxY) { return 40; } + @Override public int getHeight(Minecraft minecraft, int maxX, int maxY) { return 20; } + @Override public boolean mouseScrolled(Minecraft minecraft, double x, double y, double scroll, int maxX) { + this.delta += scroll; + return true; + } + } +} diff --git a/src/test/java/dev/anvilcraft/resource/ageratum/client/gui/RowWrappingTest.java b/src/test/java/dev/anvilcraft/resource/ageratum/client/gui/RowWrappingTest.java new file mode 100644 index 0000000..4f06aa0 --- /dev/null +++ b/src/test/java/dev/anvilcraft/resource/ageratum/client/gui/RowWrappingTest.java @@ -0,0 +1,62 @@ +package dev.anvilcraft.resource.ageratum.client.gui; + +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.MDComponent; +import dev.anvilcraft.resource.ageratum.client.feat.markdown.component.extend.MDRowComponent; +import net.minecraft.client.Minecraft; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RowWrappingTest { + @Test + void recipesWrapTogetherWithTheirHitAreas() { + var first = new RecipeBox(100, 40); + var second = new RecipeBox(100, 40); + var third = new RecipeBox(100, 40); + var row = row(List.of(first, second, third)); + assertEquals(40, row.getHeight(null, 320, Integer.MAX_VALUE)); + assertEquals(84, row.getHeight(null, 210, Integer.MAX_VALUE)); + assertTrue(row.scrollHandler(null, 56, 50, 210).test(1)); + assertEquals(1, third.events); + assertEquals(100, third.width); + assertEquals(0, first.events); + assertEquals(0, second.events); + assertFalse(row.scrollHandler(null, 5, 50, 210).test(1)); + assertEquals(128, row.getHeight(null, 100, Integer.MAX_VALUE)); + assertTrue(row.scrollHandler(null, 20, 90, 100).test(1)); + assertEquals(2, third.events); + } + + @Test + void aSingleOversizedRecipeUsesTheAvailableWidth() { + var recipe = new RecipeBox(300, 60); + var row = row(List.of(recipe)); + assertTrue(row.scrollHandler(null, 119, 10, 120).test(1)); + assertEquals(120, recipe.width); + assertFalse(row.scrollHandler(null, 121, 10, 120).test(1)); + } + + private static MDRowComponent row(List items) { + return new MDRowComponent(items, MDRowComponent.Direction.HORIZONTAL, + MDRowComponent.HorizontalAlign.CENTER, MDRowComponent.VerticalAlign.TOP); + } + + private static final class RecipeBox extends MDComponent { + private final int preferred; + private final int height; + private int width; + private int events; + private RecipeBox(int preferred, int height) { super(""); this.preferred = preferred; this.height = height; } + @Override public int getPreferredWidth(Minecraft minecraft, int maxX, int maxY) { return this.preferred; } + @Override public int getHeight(Minecraft minecraft, int maxX, int maxY) { return this.height; } + @Override public boolean mouseScrolled(Minecraft minecraft, double x, double y, double delta, int maxX) { + this.width = maxX; + this.events++; + return true; + } + } +}