SwingのDefaultStyledDocumentによる構文強調
私は今Javaとその標準APIであるSwingを使ってテキストエディタを作っています。
そのテキスト入力部分にJTextPaneを使っているのですが、
JTextPaneに入力されたテキストを字句解析し、
単語やコメントを強調表示しようと思っています。
少し調べたところ、既にそのようなことをしている方がいるようでした。
Fast styled JTextPane editor (の807606番)
これは素晴らしいソースなのですが、
拡張性がありませんでした。(ハイライトするコメントを複数登録できない)
また、コメントの開始キーが消えているのにハイライトが消えない等の問題も見つかりました。
そこでこれらの問題を解決するクラスを自分で書いてみました。
これは大体私の期待通りに動作するのですが、テキストが多くなるととても重くなります
構文強調を行わないJTextPaneならテキストをいくらでもスムーズに入力できるのですが、
今回私が書いたクラスを用いて構文強調を行うととても重たくなるようです。
(具体的にはテキストが5000行程度(JComponent.javaくらい)になると重たくなります)
恐らくどこかに効率の悪い処理が含まれているのだと思いますが、
どこを改善すれば軽くなるのでしょうか?
以下がそのソースです
SyntaxObject.java
import java.util.ArrayList;
import java.util.HashMap;
import javax.swing.text.MutableAttributeSet;
/**
* 構文リストを保持するデータオブジェクト<br>
*/
public class SyntaxObject {
protected String name;
/** 構文強調する文字列とその属性*/
protected HashMap<String, MutableAttributeSet> keywordMap;
/** 構文強調する範囲文字列の開始キー*/
protected ArrayList<String> headerList;
/** 構文強調する範囲文字列の終端キー*/
protected ArrayList<String> footerList;
/** 構文強調する範囲文字列に対応した属性*/
protected ArrayList<MutableAttributeSet> attrList;
/**
* キーワードに隣あっても構わない文字列を定義する<br>
* デフォルトでは改行、タブ、空白が定義されている
*/
protected String operands = "\n\t ";
public SyntaxObject(String name) {
this.name = name;
this.keywordMap = new HashMap<>();
this.headerList = new ArrayList<>();
this.footerList = new ArrayList<>();
this.attrList = new ArrayList<>();
}
public SyntaxObject() {
this("Default");
}
/**
* 範囲強調を追加
* @param start
* @param end
* @param attr
*/
public void add(String start, String end, MutableAttributeSet attr) {
headerList.add(start);
footerList.add(end);
attrList.add(attr);
}
/**
* 強調キーワードを追加
* @param content
* @param attr
*/
public void add(String content, MutableAttributeSet attr) {
keywordMap.put(content, attr);
}
/**
* 区切り文字を追加<br>
* 区切り文字の判定は1文字づつ行われる
* @param ch
*/
public void addOperand(char ch) {
String content = new String(new char[]{ch});
this.operands += content;
}
/**
* リストの中身を全てクリアする
*/
public void clear() {
keywordMap.clear();
headerList.clear();
footerList.clear();
attrList.clear();
}
/**
* 名前を返す
* @return
*/
public String getName() {
return name;
}
/**
* 区切り文字を返す
* @return
*/
public String getOperands() {
return operands;
}
/**
* キーワードマップのキー一覧を返す
* @return
*/
public String[] getKeywords() {
return keywordMap.keySet().toArray(new String[keywordMap.size()]);
}
/**
* 開始キーの一覧を返す
* @return
*/
public String[] getHeaders() {
return headerList.toArray(new String[headerList.size()]);
}
/**
* 終端キーの一覧を返す
* @return
*/
public String[] getFooters() {
return footerList.toArray(new String[footerList.size()]);
}
/**
* キーワードに対応した属性を返す
* @param keyword
* @return
*/
public MutableAttributeSet getAttributeOfKeyword(String keyword) {
return keywordMap.get(keyword);
}
/**
* 開始キー,終端キーに対応した属性を返す<br>
* 開始キー,終端キーのどちらかに重複するキーが存在する場合はnullを返す
* @param header
* @param footer
* @return
*/
public MutableAttributeSet getAttributeOfArea(String header, String footer) {
int hIndex = headerList.indexOf(header);
int fIndex = footerList.indexOf(footer);
//開始キー,終端キーに重複がなければ
if(hIndex == fIndex) {
return attrList.get(hIndex);
//重複するキーがある場合
} else {
return null;
}
}
}
SyntaxDocument.java
import java.awt.Color;
import java.util.ArrayList;
import javax.swing.text.AttributeSet;
import javax.swing.text.BadLocationException;
import javax.swing.text.DefaultEditorKit;
import javax.swing.text.DefaultStyledDocument;
import javax.swing.text.Element;
import javax.swing.text.JTextComponent;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
/**
* 単語や開始キー&終了キーを利用した構文強調を行える<br>
* <br>
* 参考:http://ateraimemo.com/Swing/SimpleSyntaxHighlight.html<br>
* 参考:https://community.oracle.com/thread/2105230
* @author udon
*/
public class SyntaxDocument extends DefaultStyledDocument {
/** 構文強調が有効であるか*/
protected boolean enabled = true;
/**
* 直下のエレメント<br>
* 行数取得用の変数
*/
protected Element rootElement;
/**
* スタイル情報を保持するオブジェクト<br>
* 強調する文字列,その属性はこのオブジェクトに依存する
*/
protected SyntaxObject syntaxObject;
/** 通常の文字列に利用されるスタイル*/
protected MutableAttributeSet normal;
/** 範囲強調の始点を記憶するリスト*/
protected ArrayList<Integer> headerPositionList;
/** 範囲強調の始点を記憶するリスト*/
protected ArrayList<Integer> footerPositionList;
/** 範囲強調の開始キーを記憶するリスト*/
protected ArrayList<String> headerTypeList;
/** 範囲強調の終端キーを記憶するリスト*/
protected ArrayList<String> footerTypeList;
/** テキストの終端までハイライトされたときの編集位置*/
protected int oldIndex = 0;
/** テキストの終端までハイライトされたことを示すフラグ*/
protected boolean allHighlight;
/**
*
* @param textPane このドキュメントがセットされるテキストペイン
* @param syntaxObject
*/
public SyntaxDocument(SyntaxObject syntaxObject) {
//改行文字を\nとする?
putProperty(DefaultEditorKit.EndOfLineStringProperty, "\n");
this.rootElement = getDefaultRootElement();
this.syntaxObject = syntaxObject;
this.headerPositionList = new ArrayList<>();
this.footerPositionList = new ArrayList<>();
this.headerTypeList = new ArrayList<>();
this.footerTypeList = new ArrayList<>();
//通常の文字列に利用されるスタイル
normal = new SimpleAttributeSet();
StyleConstants.setForeground(normal, Color.BLACK);
}
/**
* 構文強調の有効/無効を切り替える<br>
* 有効にすると最初から全て再読み込み,無効にするとハイライトを全てリセットする<br>
* @param enabled
* @throws BadLocationException
*/
public void setEnabled(boolean enabled) {
try {
this.enabled = enabled;
if(enabled) {
processChangedLines(0, getLength());
} else {
setCharacterAttributes(0, getLength(), normal, true);
}
} catch(BadLocationException ble) {
ble.printStackTrace();
}
}
/**
* 構文強調を行うか
* @return
*/
public boolean isEnabled() {
return enabled;
}
/**
* 新たな構文強調スタイルをセットする<br>
* 最初からテキストを再読み込みする
* @param syntaxObject
*/
public void setSyntaxObject(SyntaxObject syntaxObject) {
try {
this.syntaxObject = syntaxObject;
processChangedLines(0, getLength());
} catch(BadLocationException ble) {
ble.printStackTrace();
}
}
public SyntaxObject getSyntaxObject() {
return syntaxObject;
}
/**
* デフォルトの属性をセット
* @param attr
*/
public void setDefaultAttribute(MutableAttributeSet attr) {
try {
this.normal = attr;
processChangedLines(0, getLength());
} catch(BadLocationException ble) {
ble.printStackTrace();
}
}
@Override
public void insertString(int offset, String str, AttributeSet a) throws BadLocationException {
super.insertString(offset, str, a);
//IME変換中なら何もしない(これを行わないとIME変換中の下線が消える)
//if(isProcessingIME(str)) {
// return;
//}
processChangedLines(offset, str.length());
oldIndex = offset;
}
@Override
public void remove(int offset, int length) throws BadLocationException{
super.remove(offset, length);
processChangedLines(offset, 0);
oldIndex = offset;
}
/**
* 区切り文字か否か
* @param at
* @return
*/
protected boolean isOperand(String at) {
return syntaxObject.getOperands().contains(at);
}
/**
* シングルトークンのハイライト
* @param token 取り出された文字列
* @param startOfToken 取り出された文字の開始オフセット
* @param endOfToken 取り出された文字の終了オフセット
*/
protected void singleTokenHighlight(String token, int startOfToken, int endOfToken) {
MutableAttributeSet attr = syntaxObject.getAttributeOfKeyword(token);
if(attr != null) {
setCharacterAttributes(startOfToken, endOfToken - startOfToken, attr, true);
}
}
/**
* マルチトークンのハイライト
* @param header 開始キー
* @param footer 終了キー
* @param startOfHeader 取り出された文字の開始オフセット
* @param endOfFooter 取り出された文字の終了オフセット
*/
protected void multiTokenHighlight(String header, String footer, int startOfHeader, int endOfFooter) {
//終端までハイライトされた場合はフラグを立てる
if(endOfFooter > getLength() - 1) {
allHighlight = true;
}
MutableAttributeSet attr = syntaxObject.getAttributeOfArea(header, footer);
setCharacterAttributes(startOfHeader, endOfFooter - startOfHeader, attr, true);
headerPositionList.add(startOfHeader);
footerPositionList.add(endOfFooter);
headerTypeList.add(header);
footerTypeList.add(footer);
}
/**
* 最寄り(手前)のブロックを探し、始点の位置を返す<br>
* 範囲ハイライトの始点となる位置
* @param content
* @param offset
* @return
*/
private int lastIndexOf(String content, int offset) {
int max = content.length();
int result = 0;
int index = 0;
for(String header: syntaxObject.getHeaders()) {
int p1 = content.lastIndexOf(header, offset);
if(p1 < 0) {
p1 = content.length();
}
if(max > p1) {
max = p1;
result = index;
}
index++;
}
return result;
}
/**
* 編集が行われた行から最終行までをハイライトする
* @param offset
* @param length
* @throws BadLocationException
*/
private void processChangedLines(int offset, int length) throws BadLocationException{
//無効になっていたらなにもしない
if(!enabled) return;
//全てのテキストを取得
String content = getText(0, getLength());
//変更された行とテキストの最終行を取得
int startLine = rootElement.getElementIndex(offset);
int endLine = rootElement.getElementIndex(offset + length);
//前回のハイライトで終端までハイライトされてしまった場合終端まで読みこむ
if(allHighlight) {
startLine = rootElement.getElementIndex(oldIndex);
endLine = rootElement.getElementIndex(getLength()-1);
oldIndex = 0;
allHighlight = false;
}
//前回のハイライトのうち不要になったものを削除
removeOfUnnecessaryHighlight(content);
//一行づつ処理
for(int i=startLine; i<=endLine; i++) {
int startOffset = rootElement.getElement(i).getStartOffset();
int endOffset = rootElement.getElement(i).getEndOffset();
applyHighlighting(content, startOffset, endOffset);
}
//変更された行の開始オフセット
int nowLineStartOffset = rootElement.getElement(startLine).getStartOffset();
//範囲ハイライトを上から適用
int start = lastIndexOf(content, nowLineStartOffset);
nowLineStartOffset = start == -1 ? nowLineStartOffset : start;
overrideHighlighting(content, nowLineStartOffset);
}
/**
* キーワードを一つづつハイライトする
* @param content
* @param startOffset
* @param endOffset
*/
private void applyHighlighting(String content, int startOffset, int endOffset) {
//1行の長さ,本文全ての長さを取得
int lineLength = endOffset - startOffset;
int contentLength = content.length();
if (endOffset >= contentLength) {
endOffset = contentLength - 1;
}
//行全体に黒文字のスタイルを適用(リセット)
setCharacterAttributes(startOffset, lineLength, normal, true);
//キーワードのハイライト
checkForSingleTokens(content, startOffset, endOffset);
}
/**
* トークンがキーワードであるか判定し、キーワードであればハイライトする
* @param content
* @param startOffset
* @param endOffset
*/
private void checkForSingleTokens(String content, int startOffset, int endOffset) {
int startOfToken = startOffset;
int endOfToken = startOffset;
String buffer = "";
while(endOfToken <= endOffset) {
String at = content.substring(endOfToken, endOfToken+1);
if(isOperand(at)) {
singleTokenHighlight(buffer, startOfToken, endOfToken);
buffer = "";
} else {
if(buffer.equals("")) startOfToken = endOfToken;
buffer += at;
}
endOfToken++;
}
}
/**
* キーワードハイライトを範囲ハイライトで上書きする
* @param content
* @param offset
* @param length
*/
private void overrideHighlighting(String content, int offset) {
String clone = new String(content);
int start = 0;
int end = 0;
int deletedLength = 0;
String[] header = syntaxObject.getHeaders();
String[] footer = syntaxObject.getFooters();
//配列が空なら
if(header.length == 0 || footer.length == 0) {
return;
}
//常に次に現れるブロックのインデックスを取得し、その開始キーが存在する限り
while((start = clone.indexOf( header[getNextIndex(clone, header)]) ) > -1) {
int index = getNextIndex(clone, header);
int startOfLen = header[index].length();
int endOfLen = footer[index].length();
//終端キーを探すための開始キーから終端までを切り抜いたテキスト
String subContent = clone.substring(start + startOfLen, clone.length());
//終端キーの位置を特定
end = subContent.indexOf(footer[index]);
//終端キーが見つからなかったら最後までハイライト
if(end < 0) {
end = clone.length()+1;
multiTokenHighlight(header[index], footer[index], deletedLength + start, deletedLength + start + end + startOfLen + endOfLen);
break;
} else {
multiTokenHighlight(header[index], footer[index], deletedLength + start, deletedLength + start + end + startOfLen + endOfLen);
}
//次の検索範囲
clone = clone.substring((start+end+startOfLen+endOfLen), clone.length());
deletedLength += (start+end+startOfLen+endOfLen);
}
}
/**
* 不要になったハイライトの削除
* @param content
*/
private void removeOfUnnecessaryHighlight(String content) {
int size = headerPositionList.size();
for(int i=0; i<size; i++) {
int headerPosition = headerPositionList.get(i);
int footerPosition = footerPositionList.get(i);
//文字列が削除されることにより過去ハイライトの終端が現テキストの終端を超えた場合
if(footerPosition > content.length()) {
footerPosition = content.length();
}
//テキストが空なら何もしない
if(content.equals("") || content.isEmpty() || (footerPosition - headerPosition) < 0) {
break;
}
//過去ハイライトの始点から終点をハイライトする
String sub = content.substring(headerPosition, footerPosition);
//削除されたのが開始キーなら
if(!sub.startsWith(headerTypeList.get(i))) {
setCharacterAttributes(headerPosition, footerPosition - headerPosition, normal, true);
}
//削除されたのが終端キーなら
if(!sub.endsWith(footerTypeList.get(i))) {
//setCharacterAttributes(footerPosition, (content.length()-1) - footerPosition, normal, true);
setCharacterAttributes(headerPosition, footerPosition - headerPosition, normal, true);
}
}
//中身をリセット
headerPositionList.clear();
footerPositionList.clear();
headerTypeList.clear();
footerTypeList.clear();
}
/**
* 次の開始キーの配列番号を返す
* @param content
* @param headers
* @param offset
* @return
*/
private int getNextIndex(String content, String[] headers) {
int index = 0;
int old = content.length();
for(int i=0; i<headers.length; i++) {
int newi = content.indexOf(headers[i]);
if(newi < 0) newi = content.length();
if(newi < old) {
old = newi;
index = i;
}
}
return index;
}
}
実際には以下のように利用しています
Main.java
import java.awt.Color;
import javax.swing.JFrame;
import javax.swing.JTextPane;
import javax.swing.JScrollPane;
import javax.swing.text.MutableAttributeSet;
import javax.swing.text.SimpleAttributeSet;
import javax.swing.text.StyleConstants;
public class Main {
public static void main(String... args) {
JFrame f = new JFrame("SyntaxHighlight");
JTextPane textPane = new JTextPane();
SyntaxObject o = new SyntaxObject();
//キーワードの属性
MutableAttributeSet attr = new SimpleAttributeSet();
StyleConstants.setForeground(attr, Color.RED);
//コメントの属性
MutableAttributeSet attr2 = new SimpleAttributeSet();
StyleConstants.setForeground(attr2, Color.GREEN);
o.add("import", attr);
o.add("/*", "*/", attr2);
//テキストペインにドキュメントをセット
textPane.setStyledDocument(new SyntaxDocument(o));
f.setSize(800,600);
f.add(new JScrollPane(textPane));
f.setLocationRelativeTo(null);
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
f.setVisible(true);
}
}