我需要通过修改 cloth-config 库逻辑,为所有使用了 cloth-config 库的 mod 增加配置界面的视觉无障碍支持。
其中一个目标是让 cloth-config 的界面组件支持通过键盘在组件间导航,因此我需要阅读、理解并复用 Minecraft 源码中的相关逻辑。具体来说,包 net.minecraft.client.gui.navigation
负责通过键盘导航并选中组件,包 net.minecraft.client.gui.screen.narration
负责组织并读出所选中组件的文字描述。我没能搜索到现有的代码讲解或教程,所以打算自己写一篇。
本篇内容仅包含组件导航,即介绍源码中对包 [net.minecraft.client.gui.navigation
] 的使用、以及我们 mod 开发者如何复用这个包使自定义界面也支持组件导航。游戏版本为1.21.4
,使用的 Mappings 为 Mojang 官方发布的 Mappings,即 Mojmaps。
希望这篇文章能帮到你,也希望有更多 mod 开发者为自己 mod 的古法手工自定义界面提供视觉无障碍支持。另外,关于如何创建自定义界面,可参考 Fabric 网站上的这篇文章。
Screen
类实现了大部分 ContainerEventHandler
接口,该接口下有一个导航相关的重要方法 children()
。
1
2
3
4
5
6
7
8
|
package net.minecraft.client.gui.components.events;
public interface ContainerEventHandler extends GuiEventListener {
/**
* {@return a List containing all GUI element children of this GUI element}
*/
List<? extends GuiEventListener> children();
}
|
Screen
类有两个列表类型的类字段,children
中保存的是可导航的子组件,narratables
中保存的是可讲述的组件。两个列表中的内容是动态的,随不同条件不断刷新。
1
2
3
4
5
6
|
package net.minecraft.client.gui.screens;
public abstract class Screen {
private final List<GuiEventListener> children = Lists.<GuiEventListener>newArrayList();
private final List<NarratableEntry> narratables = Lists.<NarratableEntry>newArrayList();
}
|
PackSelectionScreen
初始化(init
方法)结束时,children
、narratables
列表中包含两个 TransferableSelectionList
实例和四个 layout
中包含的组件,运行过程中不再更新列表内容(但 TransferableSelectionList
实例中的内容随包目录中文件变化而更新)。
TransferableSelectionList
间接实现了 ContainerEventHandler
接口,实现逻辑在 AbstractContainerWidget
中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
package net.minecraft.client.gui.screens.packs;
public class PackSelectionScreen extends Screen {
private final HeaderAndFooterLayout layout = new HeaderAndFooterLayout(this);
private TransferableSelectionList availablePackList;
private TransferableSelectionList selectedPackList;
@Override
protected void init() {
this.availablePackList = this.addRenderableWidget(new TransferableSelectionList(this.minecraft, this, 200, this.height - 66, AVAILABLE_TITLE));
this.selectedPackList = this.addRenderableWidget(new TransferableSelectionList(this.minecraft, this, 200, this.height - 66, SELECTED_TITLE));
this.layout.visitWidgets(guiEventListener -> {
AbstractWidget var10000 = this.addRenderableWidget(guiEventListener);
});
}
protected <T extends GuiEventListener & Renderable & NarratableEntry> T addRenderableWidget(T widget) {
this.renderables.add(widget);
return this.addWidget(widget);
}
protected <T extends GuiEventListener & NarratableEntry> T addWidget(T listener) {
this.children.add(listener);
this.narratables.add(listener);
return listener;
}
}
|
在原版游戏中,“Avaliable” 和 “Selected” 列表中都有两个材质包。在焦点位于左侧 “Avaliable” 列表组件(TransferableSelectionList this.availablePackList
)中第一个材质包的情况下,单击 Tab
键,游戏处理逻辑如下:
nextFocusPath:29, ObjectSelectionList (net.minecraft.client.gui.components)
handleTabNavigation:186, ContainerEventHandler (net.minecraft.client.gui.components.events)
nextFocusPath:29, ObjectSelectionList (net.minecraft.client.gui.components)
nextFocusPath:151, ContainerEventHandler (net.minecraft.client.gui.components.events)
keyPressed:145, Screen (net.minecraft.client.gui.screens)
首先由 PackSelectionScreen
所继承的 Screen.keyPressed
方法处理按键事件,判断应执行 Tab
键事件对应的逻辑为将焦点移动到下一组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
public abstract class Screen {
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (keyCode == 256 && this.shouldCloseOnEsc()) {
// ...
} else {
FocusNavigationEvent focusNavigationEvent = (FocusNavigationEvent)(switch (keyCode) {
case 258 -> this.createTabEvent();
});
if (focusNavigationEvent != null) {
ComponentPath componentPath = super.nextFocusPath(focusNavigationEvent); // <--
// if (componentPath == null && focusNavigationEvent instanceof FocusNavigationEvent.TabNavigation) {
// this.clearFocus();
// componentPath = super.nextFocusPath(focusNavigationEvent);
// }
// if (componentPath != null) {
// this.changeFocus(componentPath);
// }
}
return false;
}
}
}
|
PackSelectionScreen
类的 nextFocusPath
方法继承自 ContainerEventHandler
接口。根据代码,我以为 “Avaliable” 实例的 nextFocusPath
方法会返回非 null 的结果,但结果出人意外的是 null。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
package net.minecraft.client.gui.components.events;
public interface ContainerEventHandler extends GuiEventListener {
@Override
default ComponentPath nextFocusPath(FocusNavigationEvent event) {
GuiEventListener guiEventListener = this.getFocused(); // guiEventListener = "Avaliable"
if (guiEventListener != null) {
ComponentPath componentPath = guiEventListener.nextFocusPath(event); // <-- componentPath = null, unexpected!
// if (componentPath != null) {
// return ComponentPath.path(this, componentPath);
// }
}
// if (event instanceof FocusNavigationEvent.TabNavigation tabNavigation) {
// return this.handleTabNavigation(tabNavigation);
// } else {
// return event instanceof FocusNavigationEvent.ArrowNavigation arrowNavigation ? this.handleArrowNavigation(arrowNavigation) : null;
// }
}
}
|
让我们 step into 看看 "Avaliable".nextFocusPath
里发生了什么。TransferableSelectionList
的 nextFocusPath
方法由所继承的 ObjectSelectionList
实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
package net.minecraft.client.gui.components;
public abstract class ObjectSelectionList<E extends ObjectSelectionList.Entry<E>> {
@Override
public ComponentPath nextFocusPath(FocusNavigationEvent event) {
if (this.getItemCount() == 0) { // nope, we get two packages inside "Avaliable" list
} else if (this.isFocused() && event instanceof FocusNavigationEvent.ArrowNavigation arrowNavigation) { // no, not in-list package selection
} else if (!this.isFocused()) { // nah, the "Avaliable" is focused in PackSelectionScreen
} else {
return null; // <-- So that's why we end up to a null return value
}
}
}
|
回到 PackSelectionScreen
(ContainerEventHandler
)的 nextFocusPath
继续向下走,进入 handleTabNavigation
方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
package net.minecraft.client.gui.components.events;
public interface ContainerEventHandler extends GuiEventListener {
@Override
default ComponentPath nextFocusPath(FocusNavigationEvent event) {
GuiEventListener guiEventListener = this.getFocused(); // guiEventListener = "Avaliable"
if (guiEventListener != null) {
ComponentPath componentPath = guiEventListener.nextFocusPath(event);
if (componentPath != null) {
// return ComponentPath.path(this, componentPath);
}
}
if (event instanceof FocusNavigationEvent.TabNavigation tabNavigation) {
return this.handleTabNavigation(tabNavigation); // <--
// } else {
// return event instanceof FocusNavigationEvent.ArrowNavigation arrowNavigation ? this.handleArrowNavigation(arrowNavigation) : null;
}
}
}
|
PackSelectionScreen
的 handleTabNavigation
方法依然由 ContainerEventHandler
提供。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
public interface ContainerEventHandler extends GuiEventListener {
@Nullable
private ComponentPath handleTabNavigation(FocusNavigationEvent.TabNavigation tabNavigation) {
boolean bl = tabNavigation.forward(); // yes, forward
// The purpose of this section is preparing for iterating child components,
// start from current focused component's position in `children` list,
// the iter direction is determined by `TabNavigation` event's direction.
// ---
GuiEventListener guiEventListener = this.getFocused();
List<? extends GuiEventListener> list = new ArrayList(this.children());
Collections.sort(list, Comparator.comparingInt(guiEventListenerx -> guiEventListenerx.getTabOrderGroup()));
int i = list.indexOf(guiEventListener);
int j;
if (guiEventListener != null && i >= 0) {
j = i + (bl ? 1 : 0);
} else if (bl) {
j = 0;
} else {
j = list.size();
}
ListIterator<? extends GuiEventListener> listIterator = list.listIterator(j);
BooleanSupplier booleanSupplier = bl ? listIterator::hasNext : listIterator::hasPrevious;
Supplier<? extends GuiEventListener> supplier = bl ? listIterator::next : listIterator::previous;
// ---
// Find out next
while (booleanSupplier.getAsBoolean()) {
GuiEventListener guiEventListener2 = (GuiEventListener)supplier.get(); // guiEventListener2 = "Selected" list
ComponentPath componentPath = guiEventListener2.nextFocusPath(tabNavigation); // <--
// if (componentPath != null) {
// return ComponentPath.path(this, componentPath);
// }
}
return null;
}
}
|
哦,又回到了 ObjectSelectionList.nextFocusPath
,不过这次要执行的分支改变了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
package net.minecraft.client.gui.components;
public abstract class ObjectSelectionList<E extends ObjectSelectionList.Entry<E>> {
@Override
public ComponentPath nextFocusPath(FocusNavigationEvent event) {
if (this.getItemCount() == 0) { // nope, we get two packages inside "Selected" list
} else if (this.isFocused() && event instanceof FocusNavigationEvent.ArrowNavigation arrowNavigation) { // no, not in-list package selection
} else if (!this.isFocused()) { // yes, not focused now, enter this branch
E entry2 = this.getSelected();
if (entry2 == null) {
entry2 = this.nextEntry(event.getVerticalDirectionForInitialFocus());
}
// netry2 = first package in "Selected" list
return entry2 == null ? null : ComponentPath.path(this, ComponentPath.leaf(entry2));
} else {
}
}
}
|
最终该方法返回值为 ComponentPath.path("Selected", ComponentPath.leaf("first package"))
,回到上一步的 handleTabNavigation
方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public interface ContainerEventHandler extends GuiEventListener {
@Nullable
private ComponentPath handleTabNavigation(FocusNavigationEvent.TabNavigation tabNavigation) {
// ...
while (booleanSupplier.getAsBoolean()) {
GuiEventListener guiEventListener2 = (GuiEventListener)supplier.get(); // guiEventListener2 = "Selected" list
ComponentPath componentPath = guiEventListener2.nextFocusPath(tabNavigation);
if (componentPath != null) {
return ComponentPath.path(this, componentPath); // <--
}
}
return null;
}
}
|
返回值为 ComponentPath.path("PackSelectionScreen", ComponentPath.path("Selected", ComponentPath.leaf("first package")))
,“Selected” 列表的第一个包。这个结果将一路返回到 keyPressed
方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
public abstract class Screen {
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (keyCode == 256 && this.shouldCloseOnEsc()) {
// ...
} else {
FocusNavigationEvent focusNavigationEvent = (FocusNavigationEvent)(switch (keyCode) {
case 258 -> this.createTabEvent();
});
if (focusNavigationEvent != null) {
ComponentPath componentPath = super.nextFocusPath(focusNavigationEvent);
// if (componentPath == null && focusNavigationEvent instanceof FocusNavigationEvent.TabNavigation) {
// this.clearFocus();
// componentPath = super.nextFocusPath(focusNavigationEvent);
// }
if (componentPath != null) {
this.changeFocus(componentPath); // <--
}
}
return false;
}
}
}
|
总结:单击 Tab
键使界面焦点从 “Avaliable” 列表的第一个包转移到 “Selected” 列表的第一个包。
这逻辑有点奇怪,为什么不让 Tab
键选择到 “Avaliable” 列表的第二个包,然后再次按键选择到 “Selected” 列表的第一个包?应该是可以实现的,要小心设置分支条件,当焦点已在最后一个包时返回 null。