我需要通过修改 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 基类

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 资源包选择界面

PackSelectionScreen 初始化(init 方法)结束时,childrennarratables 列表中包含两个 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 里发生了什么。TransferableSelectionListnextFocusPath 方法由所继承的 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
        }
    }
}

回到 PackSelectionScreenContainerEventHandler)的 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;
        }
    }
}

PackSelectionScreenhandleTabNavigation 方法依然由 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。

#如何复用

updatedupdated2025-04-032025-04-03