reserveword / IMBlocker

A mod for Minecraft helping control Input Methods
GNU General Public License v3.0
56 stars 12 forks source link

模组底层实现原理的建议 #36

Open LitnhJacuzzi opened 10 months ago

LitnhJacuzzi commented 10 months ago

(注:Fabric1.19.4最新版本的模组中,类TextFieldMixin注入了不存在的方法onClick导致此类注入时抛出异常注入失败,因此该版本的模组在原版所有的TextFieldWidget组件都无法正常工作。经调查,当前版本中此处注入的方法名应该为mouseClicked

实现原理建议:此模组的功能建议使用监听焦点组件的变化来实现,焦点是一个GUI系统中用于定向键盘输入的关键属性,在一个标准的GUI系统中,如果此系统被操作系统赋予了焦点,那么其中有且只有一个焦点组件,在没有全局键盘监听的情况下,键盘输入最终只会在这一个组件上产生效果。而文本框组件则是典型的focusableWidget,只有当其获得焦点时才能向其中输入文字,因此监听所有文本框组件的焦点变化可以准确地决定当前输入法的状态。下面以Fabric1.19.4来说明大概的实现方式:

首先Fabric本身并没有提供监听焦点变化的API,如果要监听文本框的焦点变化只能通过Mixin注入监听器到文本框类setFocused方法的尾部;不同Fabric模组的文本框组件类不同,要适配不同模组需要一一注入;Fabric中一个Screen隐藏时不会移除其中获得了焦点的组件的焦点,因此需要注入焦点移除的代码到MinecraftClient.setScreen()方法中。

我已在原版的文本框组件中应用了基于此原理的实现,效果很好,相关的实现部分如下:

IMCheckState

    // check overall state
    private static void syncState() {
        IMManager.makeState(focusedInputWidget != null || <screen in white list>);
    }

    public static void focusGained(Object widget) {
        focusedInputWidget = widget;
    }

    public static void focusLost(Object widget) {
        if(focusedInputWidget == widget) {
            focusedInputWidget = null;
        }
    }

TextFieldMixin

    @Inject(method = "setFocused", at = @At("TAIL"))
    public void focusChanged(boolean isFocused, CallbackInfo ci) {
        if(isFocused) {
            IMCheckState.focusGained(this);
        }else {
            IMCheckState.focusLost(this);
        }
    }

MinecraftClientMixin

    @Inject(method = "setScreen", at = @At("HEAD"))
    public void onScreenClosed(Screen screen, CallbackInfo ci) {
        IMCheckState.focusedInputWidget = null;
    }

使用焦点监听实现后,模组的规模将大幅缩小,并且当文本框内部实现发生变化时需要改动的代码也大幅减少。

更新:setFocused方法的混淆名method_25365与混淆名为class_339的类中某个方法的混淆名重名,构建时会映射失败,如果要注入setFocused方法还需在构建出来的mod中手动修改client-fabric-refmap.json文件。REI模组的文本框组件也需要手动映射setFocused方法的混淆名,与原版setFocused混淆名相同。

reserveword commented 7 months ago

这个想法我试过(不过没有你这么深入,没想到用screen关闭的情况检测文本框关闭),不过如果关闭screen之后又打开了别的screen,或者解除文本框和屏幕之间的信号传递,这种方式就会失效。

我记得为当时是因为jei的搜索框或者原版创造模式自带的搜索框有这样的情形:关闭screen(还是创造模式搜索框切走标签页来着)之后文本框的focused还是true,但是在screen收到onCharTyped函数时不会调用搜索框的onCharTyped函数,所以实际上没法输入文本的情况下还保留着开启输入法的状态。

如果你有时间的话,希望能帮我测试一下你这个方法在rei/emi/原版创造模式输入框 这三个情形下是否能正常工作

之前比较忙没时间维护这个项目,我记得回复过一次但可能被吞了,未能及时回复十分抱歉

LitnhJacuzzi commented 7 months ago

没关系,理解。我之前开了一个临时仓库IMBlocker Update来暂时填补这个模组更新的空白期,原版/REI/EMI我都使用了新的实现方式并测试过全部正常工作。不过存放这个项目源码的电脑暂时不在我身边没法发PR,目前你只能通过反编译的方式迁移更改的内容,抱歉。

焦点监听确实会与原来的实现产生一定的冲突,新的实现中已经禁用了onCharTyped监听的方式(因为它不是决定输入法状态的关键,直接消除它可能带来的副作用为好),改动的内容可能较多。一个比较关键的点是截至我最后一次构建时setFocused方法都无法正常映射混淆名,需要手动更改jar中的client-fabric-refmap.json文件,具体内容直接复制我发布的版本中的json文件即可。

我由于是独立桌面环境开发者所以对操作系统GUI接口层的设计比较熟悉,新的实现方式可能有些地方涉及一些比较深层的GUI设计规范,有疑问的话随时提出即可。

reserveword commented 7 months ago

没关系,理解。我之前开了一个临时仓库IMBlocker Update来暂时填补这个模组更新的空白期,原版/REI/EMI我都使用了新的实现方式并测试过全部正常工作。不过存放这个项目源码的电脑暂时不在我身边没法发PR,目前你只能通过反编译的方式迁移更改的内容,抱歉。

按理说我是按照GPL开源的,所以也希望你能把你的代码传到仓库上去。

我现在新想到的检测方式是hook所有带textfield字样的类(和原版TextFieldWidget),在渲染时检测是否仍然有效。就目前而言能够在原版/rei/emi上正常工作,不确定会不会有不稳定的情况。

目前imblocker的工作方式是由多个规则共同决定的,检测聚焦文本框和发送非打印字符这两个规则能分别检测激活输入法,本意是互相补充识别另一种方法无法识别的情形。如果现在大部分情况都能识别到,那就先把这个功能改成默认关闭好了。

关于你说的gui设计规范,我不太理解是什么意思。根据我之前和文本框组件搏斗的记忆,我只能总结出mc的gui是由ParentElement组成的树,Element是树上的叶子节点,渲染/点击/按键之类的事件由父节点递归调用子节点向下传递,最后得到处理。之前我使用tick事件监听文本框是否处于全局tick事件的传播路径上,1.20.4发现文本框没有tick事件了。

LitnhJacuzzi commented 7 months ago

按理说我是按照GPL开源的,所以也希望你能把你的代码传到仓库上去。

等我拿到存放源码的电脑就可以传,大概还要一周左右的时间。

GUI设计规范中,“焦点是一个GUI系统中用于定向键盘输入的关键属性,在一个标准的GUI系统中,如果此系统被操作系统赋予了焦点,那么其中有且只有一个焦点组件,在没有全局键盘监听的情况下,键盘输入最终只会在这一个组件上产生效果“,之前有提到过这一点。引出这个规范的目的是说明输入法状态最关键的决定因素——当前可以接收键盘输入的组件,是如何被定位的。虽然MC的焦点系统有些另类(这个后面会解释一下),但任何最终效果符合通用GUI系统规定的GUI都必须以某种方式遵循这一规范,MC的GUI系统也不例外,因此遵照这个规范使用焦点监听来决定输入法状态是实现imblocker的最佳方式。

一般成熟的GUI框架中都会有一个全局变量来存储当前持有焦点的组件,在键盘输入由操作系统传入GUI框架时可以很容易地将其重定向到持有焦点的组件。不过MC的GUI框架并没有遵循这个普遍的实现方式,而是自上而下遍历组件树中的每个组件寻找获得了焦点的组件,在这个组件处理完键盘输入(也就是onCharTyped返回)后结束遍历,这是一种效率很低的做法,但是不影响”只有获得了焦点的组件才能处理键盘输入“这一要求——除了一种例外:类似于告示牌编辑界面显示时键盘输入会定向到告示牌里而不是焦点组件,目前我只发现MC的GUI实现在这类Screen下不太守规矩,这种情况直接用白名单处理就行。因此大多数情况下我们存储的焦点组件总是当前接收键盘输入的组件。

还有一个值得注意的事实是MC的GUI系统没有逻辑Z轴的处理系统,只有渲染Z轴,也就是说当组件重叠显示的时候它们会同时接收一个鼠标事件(在一般的GUI系统中只有最上面的组件会接收鼠标事件),因此监听鼠标事件来决定输入法状态可能会导致意料之外的结果。

reserveword commented 7 months ago

还有一个值得注意的事实是MC的GUI系统没有逻辑Z轴的处理系统,只有渲染Z轴,也就是说当组件重叠显示的时候它们会同时接收一个鼠标事件(在一般的GUI系统中只有最上面的组件会接收鼠标事件),因此监听鼠标事件来决定输入法状态可能会导致意料之外的结果。

鼠标事件在目前的实现里只是作为一个提示存在的,因为“大部分获得/失去焦点的事件都会由鼠标事件或screen关闭触发”,所以每当出现鼠标事件时就检测一下是否还有焦点

LitnhJacuzzi commented 7 months ago

可以的话建议逐渐除去与焦点监听无关的实现,因为据我的观察不少现有实现依赖的API随原版和要适配的mod更新变化比较多,维护起来不太方便,如果只用焦点监听的话可以同时适配1.19.4和1.20所有的版本(1.18或许也行,不过我没测试过)

LitnhJacuzzi commented 7 months ago

@reserveword 已提交PR