说服 Xcode 映射 Vim 键

2021-08-05 20:51:31

我试图在标题中说清楚,但希望你没有进入这个页面,认为 Apple 提供了一种在 Xcode 13 的新 Vim 模式中添加自定义键映射的内置方法。不幸的是,这个功能并不是易于配置。相反,这是其中一种“但一切都可以通过足够的 swizzling 和指针算法进行配置”类型的帖子。第一件事:Xcode 13 是第一个提供内置 Vim 模式的 Xcode 版本!这是个大新闻——我在过去几年才真正进入 vim,但这是我认为此时我永远不会忘记的事情之一。由于这个原因,我是 XVim 插件的忠实粉丝,所以看到直接添加到 Xcode 的 vim 功能很棒。这尤其正确,因为 XVim 是我仍然经常使用的最后一个 Xcode 插件 - Xcode 中的正确 vim 功能可能意味着没有担心将来管理插件。话虽如此,虽然这无论哪种方式都是向前迈出的重要一步,但它并不完全是一个完整的实现,正如 Apple 自己相当低调的描述所表明的那样:代码编辑器和 mldr 直接支持 Vim 用户熟悉的许多常用组合键和编辑模式

当然,到目前为止,我发现自己在使用它时缺少一些功能 - 显然我喜欢使用 :25 之类的命令跳转到特定的行号 - 但是这个功能已经推出了足够的功能来涵盖我大部分时间都想做。除了一件大事,到目前为止我一直无法克服 - 我通常会将我的转义键映射到其他东西。好吧,至少在插入模式下。我的 .vimrc 文件的那部分看起来像这样: &mldr 这真的很棒 - 它映射了一系列键 - j 键,然后是快速连续的 k 键 - 退出插入模式。这是一个常见的映射,而且很好原因 - 只需在主行快速双击即可切换模式。使用的键 - 以及确切的行为,在那里你会得到一种“假”juntil 它基于另一个按键或超时来解决 - 这使得使用 Xcode 之外的软件实现这一点变得困难。但它现在不仅是我肌肉记忆的核心部分(我很惊讶在没有这个映射的情况下,字符 jk 一直出现在 Xcode 中),而且还是我不想摆脱的东西;我很久以前就发现这是我的偏好,我不想学习其他东西只是为了安抚 Xcode。与此类事情经常发生的情况一样,我们实际上只有几个步骤要涵盖:

所以让我们从第一个开始。使用 Vim 模式时,Xcode 包含一个新的底部栏,其中列出了一些当前可用的操作:一个不错的开始选择是搜索这些操作名称并尝试找到它们的使用位置 - 这不是一种超精确的方法,但它是快速且经常有效。名为 SourceEditor 的框架中的单个匹配项 - 如果有的话,我希望本地化文件中的匹配项包含“YANK_DESCRIPTION”=“yank(复制)”之类的内容,之后我们将搜索 YANK_DESCRIPTION。但是,嘿,对我有用!接下来,我们可以在像 Hopper 这样的反汇编程序中打开 SourceEditor,然后再次搜索我们的“yank (copy)”字符串。这给出的结果似乎被名为 SourceEditor.ViYankToEndOfParagraphDownCommand.init 的函数引用: 以 Vi 开头的类名好消息!我们还有更多的工作要做,但 SourceEditor 框架似乎是一个正确的起点。使用反汇编器跟踪其余部分可能比它的价值更多。相反,让我们打开 Xcode 13,将调试器附加到它,并在所有 SourceEditor.ViYankToEndOfParagraphDownCommand 方法上放置一个断点。这样,如果我们在 Vim 模式下执行 yank-to-end-of-paragraph,我们应该点击我们的断点。

% lldb (lldb ) process attach --name XcodeProcess 20279 停止 (lldb) b -r "SourceEditor.ViYankToEndOfParagraphDownCommand"断点 1:22 个位置。 (lldb ) continueProcess 20279 恢复 在这一点上,我意识到我不知道如何猛拉到段落的末尾。谷歌说 y}?不过,这似乎并没有达到断点,目前还不清楚是我做错了什么,还是这个断点没有落在有用的地方。回顾 Hopper 的其他 SourceEditor.Vi 前缀类,SourceEditor.ViReplaceCharacterCommandHandler 跳出来作为一个很好的第二个选项。我绝对知道如何替换一个字符(应该是 r,然后是替换)并且这个类有一个 CommandHandler 后缀而不是不仅仅是命令 - 听起来像是在发生替换时肯定应该有一个方法调用它的东西。目标 0:(Xcode) 停止。 (lldb) bt* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.6 * frame #0: 0x12adc4ec0 SourceEditor`协议见证 SourceEditor.CommandHandler.selectionMode.getter : Swift.Optional<SourceEditor .SourceEditorView.SourceEditorSelectionMode> 符合 SourceEditor.ViReplaceCharacterCommandHandler : SourceEditor.CommandHandler in SourceEditor frame #1: 0x12afb1882 SourceEditor`SourceEditor.CommandInterface.performCommandWithSelector(_: ObjectiveC.Selector, sender: Swift.Optional:<Any.Optional arguments> Any>...) throws -> () + 242 frame #2: 0x12ade19fe SourceEditor`SourceEditor.SourceEditorView.perform(_: Swift.Optional<ObjectiveC.Selector>, with: Swift.Optional<Any>) -> Swift。 Optional<Swift.Unmanaged<Swift.AnyObject>> + 286 frame #3: 0x12ade1b0e SourceEditor`@objc SourceEditor.SourceEditorView.perform(_: Swift.Optional<ObjectiveC.Selector>, with: Swift.Optional<Any>) -> Swift.Optional<Swift.Unmanaged<Swift.AnyObject>> + 126 帧 #4:0x12afc7b20 酸ceEditor`SourceEditor.ViCommand.perform(actions: Swift.Array<ObjectiveC.Selector>, context: SourceEditor.ViEventConsumer.Context) -> SourceEditor.ViCommand.PerformResult + 288 frame #5: 0x12afc7c1b SourceEditor`SourceEditor.ViCommand.perform(context : SourceEditor.ViEventConsumer.Context) -> SourceEditor.ViCommand.PerformResult + 43 frame #6: 0x12afeb894 SourceEditor`SourceEditor.ViEventConsumer.(在_383C3123AEDAAFA7B0BF64D9906E584E中执行)(command:SourceViCommand.SourceViEventEditor)SourceVib894 SourceEditor`SourceEditor.ViEventConsumer. .ViCommand.PerformResult + 52 帧 #7: 0x12afebfbb SourceEditor`SourceEditor.ViEventConsumer.(handle in _383C3123AEDAAFA7B0BF64D9906E584E)(commands: Swift.Array<SourceEditor.ViCommand>, context: -SourceConSourceEditor.ViEventConsumer. ViCommand.PerformResult> + 1211 frame #8: 0x12afec603 SourceEditor`SourceEditor.ViEventConsumer.handleKeyEvent(_: __C.NSEvent, in: SourceEditor.SourceEditorView) -> Swift.Bool + 1443 frame #9: 0x 13b38fe1a IDESourceEditor`IDESourceEditor.IDEViEventConsumer.handleKeyEvent(_: __C.NSEvent, in: SourceEditor.SourceEditorView) -> Swift.Bool + 938 frame #10: 0x12b0de385 SourceEditor`SourceEditor.SourceEditor:View.__C.NS() -> ) + 405 帧 #11: 0x12b0de47f SourceEditor`@objc SourceEditor.SourceEditorView.keyDown(with: __C.NSEvent) -> () + 47 帧 #12: 0x7fff22e2f908 AppKit`-[NSWindow(NSEventRouting):Eventis2Delayed48帧 #13: 0x7fff22e2dd96 AppKit`-[NSWindow(NSEventRouting) sendEvent:] + 347 帧 #14: 0x104f16674 IDEKit`-[IDEWorkspaceWindow sendEvent:] + 154 帧 #15: +7fff22e2cc11 App 3021 帧 #16: 0x104f5a189 IDEKit`-[IDEApplication sendEvent:] + 857 帧 #17: 0x7fff23104f71 AppKit`-[NSApplication _handleEvent:] + 65 帧 #18: 0x7fff22c9506e 4006NS App 框架 +8506e406NS 应用程序帧 #17 DVTKit`-[DVTApplication run] + 54 帧 #20: 0x7fff22c6924c AppKit`NSApplicationMain + 816 帧#21: 0x7fff203c1f3d libdyld.dylib`start + 1 frame #22: 0x7fff203c1f3d libdyld.dylib`start + 1 从底部开始,一些通用的应用程序/窗口逻辑正在慢慢冒出一个 NSEvent(从第 17 帧到第 12 帧)上面还有更多的 vim 处理,但随着我们的进行,事情变得更加混乱。

既然我们对 vim 命令的控制流是什么样子有了最细微的了解,很明显,如果不付出很多努力,我们可能无法理解整个系统:有事件、事件使用者、事件消费者上下文、命令、命令接口、命令处理程序和mldr即使有源代码和文档,我也可能无法理解它。相反,让我们找一个较小的领域来关注。我们主要只是想告诉 Xcode 它实际上正在接收不同的输入。这不是我们需要做的全部 - 在某些时候,我们将不得不弄清楚如何做一些事情,例如检查我们当前处于哪种 vim 模式 - 但我们可以当我们到达时烧掉那座桥。让我们从上面调用的 IDESourceEditor.IDEViEventConsumer.handleKeyEvent 方法开始。它是堆栈中最低的 vim 特定框架;足够低以至于它只真正处理 NSEvent,因此无论处理将按键映射到命令的逻辑都可能发生在这一点之后。我们可以禁用我们原来的断点并在这个 IDEViEventConsumer.handleKeyEvent 方法上添加一个新断点:回到 Xcode,我们现在可以点击任意键到达我们的断点,因为我们不再打破字符替换逻辑;我会首先使用鼠标移动到第 1 行,然后按 j 键,这应该将我们向下移动到第 2 行;断点确实命中了!目标 0:(Xcode) 停止。 (lldb ) po $arg1NSEvent: type =KeyDown loc = (544.636,595.392 ) time =111097.2 flags =0x100 win =0x7fb9ace853b0 winNum = 7437 ctxt =0x0 chars = "j" unmodchars = "j" repeat our key's j 按键 - 到目前为止是有道理的。如果我们在 lldb 中输入 continue,然后回到 Xcode,我们会看到我们已经移到了第 2 行。

现在让我们看看我们是否真的可以影响按键的结果。回到 Xcode,我们可以再次点击 j,开始向下移动到第 3 行。不过,在 lldb 中,我们将完全跳过此方法的逻辑并提前返回。基于上面的堆栈跟踪,我们的 IDEViEventConsumer.handleKeyEventmethod 预计会返回一个 Bool - 可能表明它是否实际处理了关键事件。让我们从提前返回 true 开始:目标 0: (Xcode ) 停止。 (lldb ) thread return true* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1 (lldb ) continue 在 Xcode 中检查回来后,我们仍然在第 2 行!这是有道理的 - 我们已经有效地表示 IDEViEventConsumer.handleKeyEvent 已经成功处理了 keyevent,但实际上并没有对它做任何事情。让我们试试相反。我们还在2号线;再次点击 j 使我们回到断点:Target 0: (Xcode) 停止。 (lldb ) thread return false* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1 (lldb) continue 说 IDEViEventConsumer 没有处理关键事件意味着 aj 字符被插入到我们的行中,尽管我们一直处于正常模式(事实上,我们仍然是)。

IDEViEventConsumer.handleKeyEvent 预计在 Vim 模式启用时处理所有按键。我们的线程返回 false 意味着我们退回到一些非 vim 模式处理程序,这自然只是插入了 aj 字符;这在正常使用中不会发生。 IDEViEventConsumer.handleKeyEvent 实际上只处理 vim 模式下的一些按键,例如导航或模式之间的切换。插入模式下的一般输入由链中稍后的其他事件使用者处理。这种区别可能会影响我们前进的道路,所以让我们看看我们是否可以找出哪个是正确的。最简单的方法是检查此方法在不同场景中返回的值。回到 Xcode,再次按下 j 来击中我们的断点——这一次,让我们跳出框架,然后检查 rax 寄存器以查看返回的值: 注意:如果您试图复制这篇文章中的步骤一个基于 M1 的 Mac,这里的返回值应该是 $x0;请注意,我还没有在 M1 上测试过这篇文章的任何部分 - 抱歉! (lldb)finish* thread #1, queue = 'com.apple.main-thread', stop reason = step out (lldb ) p $rax (unsigned long ) $7 = 1 那 1 表示我们的方法返回真,并且是负责相应地处理我们的 j 按下。接下来,让我们进入插入模式;我们可以按照相同的过程再次查看进入该模式的 i 按键的返回值 1。

(lldb)finish* thread #1, queue = 'com.apple.main-thread', stop reason = step out (lldb) p $rax (unsigned long ) $9 = 0 这证实了 - IDEViEventConsumer.handleKeyEvent 实际上并不负责用于在插入模式下处理事件。在这种情况下,我们希望在 SourceEditor.SourceEditorView.keyDown 中挂钩堆栈上一层的内容。这可能会使一些事情变得更难(比如找出 vim 特定的上下文,比如我们所处的模式;大概,从一个名为 IDEViEventConsumer 的类中访问它会更容易!),但为了让我们在其中操作事件,这是必要的插入模式。再说一次,它可能会让一些事情变得更容易 - 从上面的堆栈跟踪来看,看起来 SourceEditor.SourceEditorView.keyDown 有一个 @objc 入口,这意味着我们应该能够调整它。这将让我们免于尝试挂钩 swift-only 方法的大量麻烦。这可能会在某个时候拆分成自己的帖子,因为我总是很难找到一些最新的步骤来制作 Xcode 插件。我认为有一些模板可能很有用,但是我通常更喜欢知道我正在改变什么,特别是当步骤很少时,所以:从文件 > 新建项目开始,然后选择 macOS Bundle 模板。项目名称(我使用的是 XcodeVimMap)和组织标识符您,但将 Bundle Extension 更改为 xcplugin。使用 File > New > File, Cocoa Class 添加一个新的 Obj-C 头文件/实现文件。我也将使用我的项目名称作为此类名称 - XcodeVimMap。

在捆绑包的 Info.plist 中,添加一个数组类型的新 DVTPlugInCompatibilityUUIDs 键。添加您正在开发的 Xcode 应用程序版本的关联 UUID 作为数组中的字符串项;您可以使用以下方法获取 UUID:将 Principal Class 键更新为您创建的类的名称。如果你只有一个类,这不是严格需要的,但可以为你节省一些痛苦。如果这是您为此 Xcode 副本安装的第一个 Xcode 插件,请重新签署 Xcode(这可能会导致您的 Xcode 安装出现问题,但我真的没有资格知道它们是什么或谈论它们;但是如果您第一次涉足插件,可能会做一些研究)。如果您正确设置了所有内容,您应该在 Xcode 中收到一个提示,询问您是否要加载包(意外代码包“...”)。请注意,您想要的按钮是未突出显示的按钮。加载包后,您应该在终端中看到 XcodeVimMap Loaded 输出。注意:如果您看到任何有关未加载插件的输出,例如在路径'.../XcodeVimMap.xcplugin'处跳过插件,则可能与上面指定的Info.plist值有关;在做任何其他事情之前仔细检查它们。如果一切正常,您可以执行两个额外的步骤来加快开发速度:在目标的构建设置中,将安装构建产品位置更改为 $(HOME),将安装目录更改为 /Library/Application Support/Developer/Shared/Xcode/插件。这将防止您在每次构建后都必须复制产品。

在您的方案设置中,在 Run 选项卡和 Info 子选项卡上,将您的 Executable 更改为您正在开发的 Xcode 的副本。这将让您点击 Run 按钮以在构建后启动另一个 Xcode 实例,查看它的日志输出,并调试它。请注意,在撰写本文时,您还必须取消选中“选项”子选项卡中的“启用用户界面调试”,否则您将收到有关无法加载调试器插件的错误消息。 Xcode 连接调试器时速度相当慢,因此如果调试器不是您工作流程的核心部分,则值得取消选中“信息”子选项卡中的“调试可执行文件”框 - 尽管我们将在本文的部分内容中使用它。完成所有这些之后,我们现在已经设置了一个通用的 Xcode 插件——我们现在可以开始向其中添加一些特定于 vim 的代码。我们之前确定 SourceEditor.SourceEditorView.keyDown 可能是此处定位的理想方法,所以让我们从在我们的插件中混合它开始。首先,让我们在 pluginDidLoad 中的 NSLog 语句上添加一个断点:这样我们就可以查看我们想要 swizzle 的类是否在此时真正加载了:看起来类尚未加载。我们知道它是在 SourceEditor 框架中定义的,我们在本文顶部附近看到了路径;我们可以使用 dlopen 提前加载它: // #import <dlfcn.h> for dlopen NSString * xcodePath = [ [ NSBundle mainBundle ] bundlePath ] ; NSString * sourceEditorPath = [ xcodePath stringByAppendingPathComponent : @"Contents/SharedFrameworks/SourceEditor.framework/Versions/A/SourceEditor"]; dlopen([sourceEditorPath cStringUsingEncoding: NSUTF8StringEncoding], RTLD_NOW); NSLog ( @" [XcodeVimMap] SourceEditor Loaded ") ;

如果我们在这个新的 NSLog 语句上添加一个断点并重新运行,我们可以确认我们的类现在已经加载:我们现在应该能够相应地设置我们的 swizzle。请注意,我使用了一些奇怪的格式,我们在函数指针中保留对原始实现的引用,而不仅仅是使用 method_exchangeImplementations;我在通常的设置中遇到了一些奇怪的问题,宁愿将相关的调试工作保存在这篇文章的其他地方 - 有些东西告诉我我需要它。 // #import <AppKit/AppKit.h> for NSEvent // #import <objc/runtime.h> for runtime fun // 保存原始方法实现 static BOOL ( * originalKeyDown ) ( id self , SEL _cmd , NSEvent * event ); // 记录按键事件,然后转发到原始实现 - ( BOOL ) swizzled_keyDown: ( NSEvent * ) event { NSLog ( @" [XcodeVimMap] Got Characters: %@ " , event . characters ) ; return originalKeyDown( self , _cmd , event ) ; } + ( void ) pluginDidLoad: ( NSBundle * ) plugin { // ... Method originalMethod = class_getInstanceMethod ( NSClassFromString ( @" SourceEditor.SourceEditorView " ) , NSSelectorFromString ( @" keyDown: " ) ) ;方法replacementMethod = class_getInstanceMethod([self class],@selector(swizzled_keyDown:)); // 保存原始实现,以便我们可以 // 从 `swizzled_keyDown` 中调用它 originalKeyDown = ( void * ) method_getImplementation ( originalMethod ) ; // 替换方法 method_setImplementation ( originalMethod , method_getImplementation ( replacementMethod ) ) ; } [XcodeVimMap] Plugin Loaded[XcodeVimMap] SourceEditor Loaded[XcodeVimMap] Got Characters: G (//移动到文件末尾)[XcodeVimMap] Got Characters: i (//切换到插入模式)[XcodeVimMap] Got Characters: H[ XcodeVimMap] 得到字符:e[XcodeVimMap] 得到字符:l[XcodeVimMap] 得到字符:l[XcodeVimMap] 得到字符:o[XcodeVimMap] 得到字符......