通過上下文敏感的智能內容完成建議,提高最終用戶的便利性和生產率
創建 HTML 編輯器
內容助理的概念與 JFace 文本查看器(即 org.eclipse.jface.text.source.SourceViewer 類)的特定實現有關。整個 Eclipse 工作台中都使用了這個類的實例來實現各種編輯器。然而, SourceViewers 並不僅限用於 Eclipse 工作台,而是還使用在基於 SWT 和 JFace JAR 建立的任何應用程序中。本文將在 Eclipse 編輯器插件的環境中展示內容助理的實現,並給出關於如何通過“裸” SourceViewers 使用內容助理的技巧。
下面讓我們實現一個簡單的 HTML 編輯器。內容助理對 HTML 編輯可能非常有用。例如,內容助理能夠生成諸如表或鏈接等典型的 HTML 結構,或者能夠將選中的文本區域包裝到樣式標簽中。
為節省時間,我們將使用 New Plug-in Project向導之一來實現這個編輯器,以生成適當的編輯器插件。由於所生成的這個編輯器是 XML 編輯器,而 HTML 是基於 XML 的標記語言,我們只需進行一些次要的修改,將所生成的編輯器轉換為一個 HTML 編輯器。下面就讓我們開始吧。
在調用 New向導之後,選擇 Plug-in Development 和 Plug-in Project。在隨後的屏幕上,輸入項目名稱“Sample HTML Editor”。在接下來的屏幕上,定義適當的插件 ID,比如“com.bdaum.SampleHTMLEditor”。下面的屏幕允許您選擇適當的代碼生成向導。請選擇 Plug-in with an editor,如圖 1 所示。
圖 1. 帶編輯器的插件
在下一個屏幕上,修改建議的插件名稱(如果想這樣做的話)和插件類名稱,並指定一個提供者名稱。其他內容保留不變。
繼續到下一個屏幕,把建議的名稱 Editor Class Name修改為“HTMLEditor”,把 Editor Name修改為“Sample HTML Editor”,把 File Extension修改為“html, htm”,如圖 2 所示。後一個條目將把新的編輯器與具有 .html 或 .htm 文件擴展名的所有文件關聯起來。
圖 2. 編輯器選項
單擊 Finish按鈕來生成新的編輯器。現在通過 Run > Run as ... > Run-time workbench啟動一個新的工作台。在創建具有 .html 或 .htm 文件擴展名的新文件(或導入這樣的文件)之後,再使用新的編輯器來打開它。
添加內容助理
正如您很快將會發現的,這個編輯器沒有具備內容助理特性;按 Ctrl + 空格鍵沒有任何作用。 SourceViewers 默認情況下沒有配備內容助理。我們需要相應地配置這個 HTML 編輯器中使用的 SourceViewer。
HTML 編輯器的 SourceViewer 的配置是通過所生成的類 XMLConfiguration 來表示的,這個類是 SourceViewerConfiguration 的子類(如果您願意,可以將這個類重命名為 HTMLConfiguration ,不過這並不是必需的)。為了向源代碼查看器添加一個內容助理,我們需要重寫 SourceViewerConfiguration 方法 getContentAssistant() 。這最適合通過 Java 編輯器的上下文功能 Source > Override/Implement Methods...來完成,這個功能會為該方法創建一個存根(stub)。現在我們需要實現這個方法,並返回一個 IContentAssistant 類型的適當實例。
內容助理由一個或多個內容處理器組成,我們想要支持的每種內容類型分別有一個內容處理器。源代碼查看器處理過的文檔可以劃分為具有不同內容類型的多個分區。這樣的分區將由分區掃描程序確定,事實上,我們在包 com.bdaum.HTMLEditor.editors 中發現了一個類 XMLPartitionScanner 。這個類為我們的文檔類型 XML_DEFAULT 、 XML_COMMENT 和 XML_TAG 定義了三種不同的內容類型。此外,文檔也可能包含 IDocument.DEFAULT_CONTENT_TYPE 類型的分區。
在新方法 getContentAssistant() 中,我們首先創建了 IContentAssistant 的默認實現的一個新實例,並給它配備了針對 XML_DEFAULT 、 XML_TAG 和 IDocument.DEFAULT_CONTENT_TYPE 內容類型的完全一樣的內容助理處理器。由於不打算在 HTML 注釋內提供輔助,因此我們沒有為內容類型 XML_COMMENT 創建內容助理處理器。清單 1 顯示了該代碼。
清單 1. getContentAssistant
public IContentAssistant getContentAssistant(SourceViewer sourceViewer) {
// Create content assistant
ContentAssistant assistant = new ContentAssistant();
// Create content assistant processor
IContentAssistProcessor processor = new HtmlContentAssistProcessor();
// Set this processor for each supported content type
assistant.setContentAssistProcessor(processor, XMLPartitionScanner.XML_TAG);
assistant.setContentAssistProcessor(processor, XMLPartitionScanner.XML_DEFAULT);
assistant.setContentAssistProcessor(processor, IDocument.DEFAULT_CONTENT_TYPE);
// Return the content assistant
return assistant;
}
實現內容助理處理器
類 HtmlContentAssistProcessor 還不存在。現在通過單擊 QuickFix燈泡狀圖標來創建它。在這個新類中,我們只需完成從接口 IContentAssistProcessor 繼承來的預先生成的方法。我們最感興趣的方法是 computeCompletionProposals() 。這個方法返回一個 CompletionProposal 實例數組,我們提供的每個建議分別有一個實例。例如,我們可以提供所有 HTML 標簽的集合以供選擇。然而,我們希望它更高級一點。當在編輯器中選中一個文本范圍時,我們希望提供一個可用於包裝這段文本的樣式標簽集合。否則,我們就提供用於創建新 HTML 結構的標簽。圖 3 和圖 4 顯示了我們想要達到的效果。
圖 3. structProposal
圖 4. styleProposal
因此,首先要從編輯器的 SourceViewer 實例中檢索當前選中的內容(參見清單 2)。
清單 2. computeCompletionProposals
public ICompletionProposal[] computeCompletionProposals(ITextViewer viewer,
int documentOffset) {
// Retrieve current document
IDocument doc = viewer.getDocument();
// Retrieve current selection range
Point selectedRange = viewer.getSelectedRange();
然後創建一個 ArrayList 實例 ,用於收集所生成的 ICompletionProposal 實例,如清單 3 所示。
清單 3. computeCompletionProposals (續)
List propList = new ArrayList();
如果選中了文本范圍,則檢索選中的文本,並計算出樣式標簽建議,如清單 4 所示。
清單 4. computeCompletionProposals (續)
if (selectedRange.y > 0) {
try {
// Retrieve selected text
String text = doc.get(selectedRange.x, selectedRange.y);
// Compute completion proposals
computeStyleProposals(text, selectedRange, propList);
} catch (BadLocationException e) {
}
} else {
否則,設法從文檔中檢索一個限定符,如清單 5 所示。這樣的限定符包含部分地進入 HTML 標簽的所有字符,用來限制可能的建議集。
清單 5. computeCompletionProposals (續)
// Retrieve qualifier
String qualifier = getQualifier(doc, documentOffset);
// Compute completion proposals
computeStructureProposals(qualifier, documentOffset, propList);
}
最後,將自動完成建議列表轉換為一個數組,並將這個數組作為結果返回,如清單 6 所示。
清單 6. computeCompletionProposals (續)
// Create completion proposal array
ICompletionProposal[] proposals = new ICompletionProposal[propList.size()];
// and fill with list elements
propList.toArray(proposals);
// Return the proposals
return proposals;
}
構造限定符
現在,讓我們看看如何從當前文檔檢索限定符。我們需要實現方法 getQualifier() ,如清單 7 所示。
清單 7. getQualifier
private String getQualifier(IDocument doc, int documentOffset) {
// Use string buffer to collect characters
StringBuffer buf = new StringBuffer();
while (true) {
try {
// Read character backwards
char c = doc.getChar(--documentOffset);
// This was not the start of a tag
if (c == '>' || Character.isWhitespace(c))
return "";
// Collect character
buf.append(c);
// Start of tag. Return qualifier
if (c == '<')
return buf.reverse().toString();
} catch (BadLocationException e) {
// Document start reached, no tag found
return "";
}
}
}
這是相當簡單的。我們從當前文檔偏移位置開始,向後讀取文檔字符。當檢測到一個開括號時,我們就找到了一個標簽的開頭,並將收集到的字符在逆轉順序之後返回。在無法找到標簽開頭的其他所有情況下,我們返回一個空字符串。在這樣的情況下,建議集是不受限制的。
編譯自動完成建議
現在讓我們編譯一個建議集合。清單 8 顯示了構成這些建議的相關標簽集。如果您願意,還可以添加更多的標簽。
清單 8. 建議集合
// Proposal part before cursor
private final static String[] STRUCTTAGS1 =
new String[] { "<P>", "<A SRC=\"", "<TABLE>", "<TR>", "<TD>" };
// Proposal part after cursor
private final static String[] STRUCTTAGS2 =
new String[] { "", "\"></A>", "</TABLE>", "</TR>", "</TD>" }
可以看到,我們將每個標簽建議劃分為兩個部分:一部分在預計的光標位置之前,一部分在預計的光標位置之後。清單 9 顯示了編譯這些建議的 computeStructureProposals() 方法。
清單 9. computeStructureProposals
private void computeStructureProposals(String qualifier, int documentOffset, List propList) {
int qlen = qualifier.length();
// Loop through all proposals
for (int i = 0; i < STRUCTTAGS1.length; i++) {
String startTag = STRUCTTAGS1[i];
// Check if proposal matches qualifier
if (startTag.startsWith(qualifier)) {
// Yes -- compute whole proposal text
String text = startTag + STRUCTTAGS2[i];
// Derive cursor position
int cursor = startTag.length();
// Construct proposal
CompletionProposal proposal =
new CompletionProposal(text, documentOffset - qlen, qlen, cursor);
// and add to result list
propList.add(proposal);
}
}
}
我們遍歷標簽數組,選擇以指定限定符開頭的所有標簽。對於每個選定的標簽,我們創建一個新的 CompletionProposal 實例。對於參數,我們傳遞完整的標簽文本、這段文本應該插入的位置、文檔中應該被替換的文本的長度(也就是限定符的長度),以及相對於插入文本開頭的預計光標位置。
這個方法將為我們提供 WYSIWYG(“所見即所得”)的建議。內容助理的彈出窗口將列出建議,其形式與它們被選定時插入文檔的形式精確一致。
處理復雜建議
前述方法並不適合於我們還必須實現的方法 computeStyleProposals() 。這裡我們需要將選中的文本包裝到選定的樣式標簽中,並使用這個新的字符串替換文檔裡選中的文本。由於這樣的替換可能具有任何長度,在內容助理選擇窗口中顯示它是沒有意義的。相反,顯示一段簡短而有意義的說明性文字,一旦選定明確的樣式建議,就顯示一個包含完整替換文本的預覽窗口,這樣會更有意義。我們可以通過使用 CompletionProposal() 構造函數的一種擴展形式來實現這點。
清單 10 顯示了我們想要支持的樣式標簽以及關聯的說明文字。同樣,您可能希望添加更多的標簽。
清單 10. 樣式標簽集合
private final static String[] STYLETAGS = new String[] {
"b", "i", "code", "strong"
};
private final static String[] STYLELABELS = new String[] {
"bold", "italic", "code", "strong"
};
清單 11 顯示了方法 computeStyleProposals()。
清單 11. computeStyleProposals
private void computeStyleProposals(String selectedText, Point selectedRange, List propList) {
// Loop through all styles
for (int i = 0; i < STYLETAGS.length; i++) {
String tag = STYLETAGS[i];
// Compute replacement text
String replacement = "<" + tag + ">" + selectedText + "</" + tag + ">";
// Derive cursor position
int cursor = tag.length()+2;
// Compute a suitable context information
IContextInformation contextInfo =
new ContextInformation(null, STYLELABELS[i]+" Style");
// Construct proposal
CompletionProposal proposal = new CompletionProposal(replacement,
selectedRange.x, selectedRange.y, cursor, null, STYLELABELS[i],
contextInfo, replacement);
// and add to result list
propList.add(proposal);
}
}
對於每種受支持的樣式標簽,我們將構造一個替換字符串,並創建一個新的自動完成建議。當然,這種解決辦法是相當簡單的。恰當的實現應該進一步檢查替換字符串。如果這個字符串包含標簽,我們將相應地對該字符串分段,分別將單獨的段包括在新的樣式標簽內。
顯示額外信息
CompletionProposal() 構造函數的前四個參數與它們在 computeStructureProposals() 方法中一樣具有相同的含義(替換字符串、插入點、被替換的文本的長度,以及相對於插入點的光標位置)。第五個參數(在本例中我們將 null 傳遞給它)接受一個圖像實例。這個圖像將顯示在彈出窗口中相應條目的左側。第六個參數接受出現在建議選擇窗口中的畫面說明文字。第七個參數用於 IContextInformation 實例,我們很快就會討論它。最後,第八個參數接受附加信息窗口中的文本,當某條建議被選定時,這段文本就應該顯示出來。然而,僅只是為這個參數提供一個值,並不足以實際獲得這樣的信息窗口。我們必須相應地配置內容助理。同樣地,這是在類 XMLConfiguration 中完成的。我們只需向方法 getContentAssistant() 添加如清單 12 所示的行。
清單 12. 向 getContentAssistant 添加行
assistant.setInformationControlCreator(getInformationControlCreator(sourceViewer));
這裡發生了什麼呢?首先,我們從當前源代碼查看器配置中獲得了一個 IInformationControlCreator 類型的實例。這個實例是一個負責創建類 DefaultInformationControl 的實例的工廠,所創建的實例將負責管理信息窗口。然後我們告訴內容助理關於這個工廠的信息。內容助理最終將在某個自動完成建議被選定時,使用這個工廠來創建一個新的信息控制實例。
格式化信息文本
默認情況下,這個信息控制實例將以純文本的形式提供附加的信息文本。然而,添加一些美妙的文本表示形式是可以做到的。例如,我們可能希望以粗體打印所有標簽。為此,我們需要相應地配置 IInformationControlCreator 創建的 DefaultInformationControl 實例。實現這點的惟一辦法是使用一個不同的 IInformationControlCreator ,而這可以通過重寫 XMLConfiguration 方法 getInformationControlCreator() 來完成。
這個方法在 SourceViewerConfiguration 類中的標准實現如清單 13 所示。
清單 13. getInformationControlCreator
public IInformationControlCreator getInformationControlCreator
(ISourceViewer sourceViewer) {
return new IInformationControlCreator() {
public IInformationControl createInformationControl(Shell parent) {
return new DefaultInformationControl(parent);
}
};
}
我們通過向 DefaultInformationControl() 構造函數添加一個 DefaultInformationControl.IInformationPresenter 類型的文本展示器(presenter),從而修改 DefaultInformationControl 實例的創建,如清單 14 所示。
清單 14. 添加文本展示器
return new DefaultInformationControl(parent, presenter);
最後剩下的事情就是實現這個文本展示器,如清單 15 所示。
清單 15. 文本展示器
private static final DefaultInformationControl.IInformationPresenter
presenter = new DefaultInformationControl.IInformationPresenter() {
public String updatePresentation(Display display, String infoText,
TextPresentation presentation, int maxWidth, int maxHeight) {
int start = -1;
// Loop over all characters of information text
for (int i = 0; i < infoText.length(); i++) {
switch (infoText.charAt(i)) {
case '<' :
// Remember start of tag
start = i;
break;
case '>' :
if (start >= 0) {
// We have found a tag and create a new style range
StyleRange range =
new StyleRange(start, i - start + 1, null, null, SWT.BOLD)
// Add this style range to the presentation
presentation.addStyleRange(range);
// Reset tag start indicator
start = -1;
}
break;
}
}
// Return the information text
return infoText;
}
};
處理過程是在 updatePresentation() 方法中完成的。這個方法接受要顯示的文本和一個默認的 TextPresentation 實例。我們只需循環遍歷該文本的字符,針對從該文本中找到的每個 XML 標簽,我們向它的這個文本展示實例添加一個新的樣式范圍。在這個新的樣式范圍中,我們保留前景色和背景色不變,但是把字體樣式設為粗體。
上下文信息
現在研究一下我們已在 computeStyleProposals() 方法中創建的 ContextInformation 實例。這個上下文信息將在某個建議已被插入文檔之後顯示出來。它可用於通知用戶關於某個自動完成建議已被成功應用的信息。然而,僅只是向 CompletionProposal() 構造函數傳遞一個 ContextInformation 實例是不足夠的。我們還必須完成方法 getContextInformationValidator() 來為這個上下文信息提供驗證器。清單 16 顯示了這是如何進行的。
清單 16. getContextInformationValidator
public IContextInformationValidator getContextInformationValidator() {
return new ContextInformationValidator(this);
}
這裡使用了 ContextInformationValidator 的默認實現。這個驗證器將檢查要顯示的上下文信息是否包含在方法 computeContextInformation() 返回的上下文信息項數組中。如果沒有,該信息將不會顯示。因此我們還必須完成方法 computeContextInformation() ,如清單 17 所示。
清單 17. computeContextInformation
public IContextInformation[] computeContextInformation(ITextViewer viewer,
int documentOffset) {
// Retrieve selected range
Point selectedRange = viewer.getSelectedRange();
if (selectedRange.y > 0) {
// Text is selected. Create a context information array.
ContextInformation[] contextInfos = new ContextInformation[STYLELABELS.length];
// Create one context information item for each style
for (int i = 0; i < STYLELABELS.length; i++)
contextInfos[i] = new ContextInformation(null, STYLELABELS[i]+" Style");
return contextInfos;
}
return new ContextInformation[0];
}
這裡我們僅為每個樣式標簽創建一個 IContextInformation 項。當然,這種解決辦法相當簡單。更高級的實現應該檢查選中文本的周圍,並確定哪些具體的樣式標簽適用於選中的文本。
如果我們不想實現這個方法,還可以選擇實現我們自己的 IContextInformationValidator ,它總是返回 true。
激活助理
到目前為止,我們已經完成了新內容助理的主要邏輯。但是在測試該插件時,按下 Ctrl + 空格鍵的時候仍然什麼事情也沒有發生。當然,它為什麼要發生呢?當這個組合鍵按下時,源代碼查看器仍然不知道我們想要一個自動完成建議列表。
在獨立的 SWT/JFace 應用程序中,我們會向源代碼查看器添加一個驗證偵聽器(參見清單 18),並檢查這個組合鍵。按下 Ctrl + 空格鍵將觸發內容助理操作,並且會禁止這個組合鍵的事件,以便源代碼查看器不會進一步處理它。
清單 18. VerifyKeyListener
sourceViewer.appendVerifyKeyListener(
new VerifyKeyListener() {
public void verifyKey(VerifyEvent event) {
// Check for Ctrl+Spacebar
if (event.stateMask == SWT.CTRL && event.character == ' ') {
// Check if source viewer is able to perform operation
if (sourceViewer.canDoOperation(SourceViewer.CONTENTASSIST_PROPOSALS))
// Perform operation
sourceViewer.doOperation(SourceViewer.CONTENTASSIST_PROPOSALS);
// Veto this key press to avoid further processing
event.doit = false;
}
}
});
然而在工作台編輯器環境中(我們的 HTML 編輯器插件就是這種情況),我們不需要深入事件處理的細節。相反,我們會創建一個適當的 TextOperationAction 來調用內容助理操作。這是通過擴展類 HTMLEditor 中的方法 createActions() 來完成的。只需確保在包 SampleHTMLEditor 中創建一個文件 SampleHTMLEditorPluginResources.properties 來滿足該插件資源包的請求即可!
清單 19. TextOperationAction
private static final String CONTENTASSIST_PROPOSAL_ID =
"com.bdaum.HTMLeditor.ContentAssistProposal";
protected void createActions() {
super.createActions();
// This action will fire a CONTENTASSIST_PROPOSALS operation
// when executed
IAction action =
new TextOperationAction(SampleHTMLEditorPlugin.getDefault().getResourceBundle(),
"ContentAssistProposal", this, SourceViewer.CONTENTASSIST_PROPOSALS);
action.setActionDefinitionId(CONTENTASSIST_PROPOSAL_ID);
// Tell the editor about this new action
setAction(CONTENTASSIST_PROPOSAL_ID, action);
// Tell the editor to execute this action
// when Ctrl+Spacebar is pressed
setActionActivationCode(CONTENTASSIST_PROPOSAL_ID,' ', -1, SWT.CTRL);
}
現在可以再次測試這個插件。這時應該能夠按 Ctrl + 空格鍵來調用內容助理了。您也許想要根據文本是否被選中來嘗試內容助理的不同行為。
而且,我們還可以添加更多的代碼。例如,當鍵入 '<'字符時,內容助理可以自動激活它自己。這可以通過向內容助理處理器指定這個自動激活字符來實現(就像每種文檔類型能夠具有特定的處理器一樣,同樣可以針對每種文檔類型使用不同的自動激活字符)。為此,我們完成了類 HtmlContentAssistProcessor 中的方法 getCompletionProposalAutoActivationCharacters 的定義,如清單 20 所示。
清單 20. getCompletionProposalAutoActivationCharacters
public char[] getCompletionProposalAutoActivationCharacters() {
return new char[] { '<' };
}
此外,我們必須啟用自動激活並設置自動激活延遲。這是在類 XMLConfiguration 中完成的。我們向方法 getContentAssistant() 添加如清單 21 所示的行。
清單 21. 向 getContentAssistant 添加行
assistant.enableAutoActivation(true);
assistant.setAutoActivationDelay(500);
最後,我們也許希望改變內容助理彈出窗口的背景色,以把它和附加信息窗口區別開來。因此,我們向方法 getContentAssistant() 添加了額外兩行,如清單 22 所示。
清單 22. 向 getContentAssistant 添加行
Color bgColor = colorManager.getColor(new RGB(230,255,230));
assistant.setProposalSelectorBackground(bgColor);
注意,我們使用了 XMLConfiguration 實例的顏色管理器來創建新的顏色。當顏色不再需要時,這樣為我們省去了清除它的麻煩,因為顏色管理器會負責顏色的清除。
高級概念
現在,在成功地為我們的 HTML 管理器實現內容助理之後,您或許想知道基於模板的內容助理是如何工作的,以及它是如何實現的。我們從 Java 源代碼編輯器中知道,這些內容助理具有一個特別的特性:它們的自動完成建議是可以參數化的。這類建議中的特定名稱可由用戶修改,結果使得該名稱在整個建議中的所有實例被同時更新。
遺憾的是,這個功能是 Eclipse Java 開發工具包(JDT)插件的一部分,因而不管是基於 SWT/JFace 的獨立應用程序,還是最簡單的 Eclipse 平台,凡是沒有這個插件的應用程序,都無法使用這個功能。幸運的是,這個功能的源代碼可供使用,並且調整它以適應其他環境並不困難。特別是, org.eclipse.jdt.internal.ui.text.java 包中的類 ExperimentalProposal 以及 org.eclipse.jdt.internal.ui.text.link 包中的類型 ILinkedPositionListener 、 LinkedPositionUI 和 LinkedPositionManager 實現了這個功能。