拦截文本编辑器中的键盘事件是很常见的一个需求。就我来说,我需要检测用户有没有输入特定的字符,然后进行一些处理。在VS中并没有现成的键盘事件供你调用。如果需要监听键盘事件,需要实现一系列的方法。下面我们来介绍并实现。
对于vs中每一个正在编辑的文档(其实也是一个window窗口),如果我们需要知道他里面发生的消息/事件,就我目前所知的有两个方法:
1. 给这个文档TextView增加CommandFilter ,拦截vs传递过来且被包装好的各种消息。
2. 得到正在编辑窗口的句柄,然后通过子类化这个窗口来得到正在发生的事件(注意这里得到和拦截的区别。得到是指你只知道发生了什么,当你不能改变它的routing)
两种方法我都进行过尝试。先尝试的第二种。因为他“看起来”简单一点,一旦我们子类化了这个编辑窗口我们就可以使用我们熟悉的winform处理消息的一些方式来进行处理,从而抛开令人困惑的COM表达。但是不幸的是,第二种一直没有成功,总是不能得到当前TextView中的消息。无奈,转向第一种方式。Here we go!
要使用第一个方法总体分两步走:
1. 打开每个文档的时候,自动给这个文档添加Filter。以便我们能够知道里面发生的一些消息。
2. 第二部就是实现这个具体的filter
拦截第一步
先从单独的文档消息拦截开始,看代码:
public class CommandFilter : IOleCommandTarget { public IOleCommandTarget NextCommandTarget; public int QueryStatus(ref Guid pguidCmdGroup, uint cCmds, OLECMD[] prgCmds, IntPtr pCmdText) { return NextCommandTarget.QueryStatus(ref pguidCmdGroup, cCmds, prgCmds, pCmdText); } public int Exec(ref Guid pguidCmdGroup, uint nCmdID, uint nCmdexecopt, IntPtr pvaIn, IntPtr pvaOut) { if (pguidCmdGroup == typeof(VSConstants.VSStd2KCmdID).GUID) { switch (nCmdID) { case (uint)VSConstants.VSStd2KCmdID.RETURN: MessageBox.Show("enter"); break; default:break; } } return NextCommandTarget.Exec(pguidCmdGroup, nCmdID, nCmdexecopt, pvaIn, pvaOut); } }
可以看到,所谓的filter就是一个类继承了IOleCommandTarget 然后实现里面的方法(如果你想搞vsx,那么你就一定要习惯里面各种各样的看起来不是很容易理解的接口)。IOleCommandTarget 有两个方法,一个查询,一个执行。执行之前先查询。为什么要这样做?还记得我吗vsx2里面讲动态菜单的时候,也要先查询一个queryStatus吗?我猜想的就是如果你需要提前做什么属性更改的动作,那么这个方法提供了一个很好的时机。在这里我们没有特殊的要求,所以直接返回NextCommandTarget.QueryStatus。
这里解释一下NextCommandTarget这个对象。我们在前面也已经提到了这种拦截方式可以截断消息的传递,也就是说如果你不返回这个NextCommandTarget,那么默认的操作就失效了。举个例子,我在vs里面按下了enter键,如果我exec里面什么也不写,那么你在vs里面将看不到任何变化,因为消息被你截断了。vs收不到任何需要操作的消息了。至于NextCommandTarget这个对象怎么赋值,我们后面会提到。
现在我们主要看exec里面的内容。当代码执行到这里,那就是vs真正需要执行一些操作了。正如我们前面提到这个方式拦截到的消息都是经过vs包装过的。怎么包装的?通过guid和cmdId。这两个组合就是典型的一个命令,所以,你收到的其实是一个命令而不是一个实际的物理消息。可能说的有点迷惑,举个例子吧。我在vs里面按下了ctrl+z,通常这个命令是撤销上一次的操作。那么我们在这里接收到的其实就是这个command而不是ctrl+z这个物理按键,我们不知道用户按下了什么(不管这种方式如何,我们目前也只能接受这个现状了)。VSConstants.VSStd2KCmdID这个对象里面包含了各种各样的命令,我们作为测试使用了return这个命令,即回车键按下的事件。做完后,记得返回NextCommandTarget.Exec,不然就仅仅是弹出一个对话框而不会换行了。
在这里顺便记录一下我在做这一块放下的错误,我当时错误把返回值搞成了这样:
NextCommandTarget.Exec(pguidCmdGroup, nCmdID, nCmdexecopt, pvaIn, pvaOut); return VSConstants.S_OK;
我当时的理解是,NextCommandTarget.Exec是告诉vs执行他默认的动作,然后返回vs_ok告诉shell我这一步正确完成了。如果你这样么做了,那么你会发现你的一些命令(如ctrl+s保存命令)失效了。就这么个问题,折磨了我两天,以至于我第一次到over stackflow和MSDN上提问。还好,大家都很热情,很快得到回复了,还是感到比较惊喜的。
提问链接:
Jared Parsons的回复(vsVim插件的作者),他的博客:http://blogs.msdn.com/jaredpar
看过上面的链接,你应该知道为什么那样是错的了。所以说,自认为害死人那...
拦截第二步
仅仅做过上面的代码之后,还不能正确拦截消息。因为没有将这个filter加入到文档中去。为了方便,我们是在每次打开文档的时刻进行注册的这个filter的。代码如下:
public class TextManagerEventSink : IVsTextManagerEvents { public void OnRegisterMarkerType(int iMarkerType) { } public void OnRegisterView(IVsTextView pView) { CommandFilter filter = new CommandFilter(); pView.AddCommandFilter(filter, out filter.NextCommandTarget); } public void OnUnregisterView(IVsTextView pView) { } public void OnUserPreferencesChanged(VIEWPREFERENCES[] pViewPrefs, FRAMEPREFERENCES[] pFramePrefs, LANGPREFERENCES[] pLangPrefs, FONTCOLORPREFERENCES[] pColorPrefs) { } }
通过IVsTextManagerEvents 这个接口,我们可以在它的OnRegisterView方法中注册每个打开的文档,为每个文档增加filter。这里你可以看到NextCommandTarget怎样赋值的吧?!其他的方法我们暂时用不到所以我们先不管。做好了这一步后,就该关注如何让vs使用这个textManager来进行文档的注册了(你不用就直接声明个TextManagerEventSink 类,肯定不会触发注册事件的)。那么做怎么让VS注册这个Manager事件呢,看下面的代码,这段代码可以写在initialize方法中,这样插件运行后就可以注册事件了。
IConnectionPointContainer textManager = (IConnectionPointContainer)GetService(typeof(SVsTextManager)); Guid interfaceGuid = typeof(IVsTextManagerEvents).GUID; textManager.FindConnectionPoint(ref interfaceGuid, out tmConnectionPoint); tmConnectionPoint.Advise(new TextManagerEventSink(), out tmConnectionCookie);
说实话,这句话我现在也迷糊。这种做法在很多的事件注册中都遇到过,大家就当固定用法吧,这东西没什么所以然来。
最后,回顾一下拦截编辑器中消息的步骤:
- 创建一个自己的CommandFilter
- 在TextManager的OnRegisterView中向文档注册这个commandFilter
- 在合适的地方注册TextManager事件,以便让OnRegisterView被触发
参考文档:
http://www.ngedit.com/a_intercept_keys_visual_studio_text_editor.html