2. Sample command ShowMatchingCharCmd

When a character such as ')' is typed, ShowMatchingCharCmd inserts this character at caret position and then highlights matching '(' for half a second. This is a slightly simplified version of command showMatchingChar in XMLmind XML Editor - Commands.

2.1. How it works

When a character such as ')' is typed, ShowMatchingCharCmd inserts this character at caret position and then highlights matching '(' for half a second.

When writing such command, a developer may be tempted to find the bounding box of the matching '(' using methods such as DocumentView.modelToView and to directly draw the highlight on the DocumentPane containing the DocumentView.

Commands should never do this. Commands should only deal with nodes and selection marks.

Nodes which are contained in a Document are rendered graphically using NodeViews created by a ViewFactory. Similarly, Marks which are attached to Nodes and which managed by a MarkManager are rendered graphically using Highlights created by a HighlightFactory.

Highlighting text involves marks than dot, mark, selected and selected2. Dot, mark, selected, selected2 are “special marks” only because standard commands operate on them. An application can create its own marks for any purpose.

A custom mark has an ID (any Object which implements hashCode and equals will do) and is attached to a Node. It is created using MarkManager.set(Object id, Node node) for NodeMarks and MarkManager.set(Object id, TextNode text, int offset) for TextLocations.

Standard commands completely ignore these custom marks. For example, standard command cancelSelection in XMLmind XML Editor - Commands leaves these marks intact. The only way to get rid of them is either to remove them using MarkManager.remove or to delete the Nodes they are attached to.

Custom marks are rendered graphically using Highlights created by the HighlightFactory of the DocumentView. By default, the HighlightFactory of a DocumentView is a BasicHighlightFactory.

A BasicHighlightFactory creates Highlights which are:

  • Similar to the caret for a TextLocation with a string ID which starts with "bookmark.".

    For example, it will create a colored vertical bar after a text location marked "bookmark.foo".

  • Similar to the text selection for two TextLocations with string IDs which start with "highlight." and "highlight2.".

    For example, it will create a colored background behind the area between a text location marked "highlight.bar" and another text location marked "highlight2.bar".

  • Similar to the node selection for two NodeMarks with string IDs which start with "highlight." and "highlight2.".

    For example, it will create a colored box around the area between a node marked "highlight.wiz" and its sibling node marked "highlight2.wiz".

Therefore, in order to highlight the matching '(', ShowMatchingCharCmd creates a TextLocation named "highlight.matchingCharN" before the matching '(' and another TextLocation named "highlight2.matchingCharN" after the matching '('. After half a second, ShowMatchingCharCmd automatically removes these marks.

2.2. First step: prepare

Excerpts from ShowMatchingCharCmd.java:

public class ShowMatchingCharCmd extends CommandBase
                                 implements Traversal.Handler {
    private int highlightId;

    private char matchingChar;
    private char insertedChar;
    private int charCount;
    
    public ShowMatchingCharCmd() {
        super(/*repeatable*/ false, /*recordable*/ true);
        highlightId = 0;
    }
    
    public boolean prepare(DocumentView docView, 
                           String parameter, int x, int y) {
        if (parameter == null || parameter.length() != 1) {
            return false;
        }

        return docView.canInsertString();
    }

ShowMatchingCharCmd can be executed if:

  • The document view is not empty.

  • The document loaded in the document view contains some text. In such case, the caret shows where to insert ')', ']' or '}'.

  • The text node containing the caret has not been marked as being read-only.

All the above conditions are tested by DocumentView.canInsertString.

2.3. Second step: doExecute

    public CommandResult doExecute(DocumentView docView, 
                                   String parameter, int x, int y) {
        String s = parameter.substring(0, 1);
        docView.insertString(s, docView.getOverwriteMode());1

        insertedChar = s.charAt(0);
        switch (insertedChar) {2
        case '}':
            matchingChar = '{';
            break;
        case ')':
            matchingChar = '(';
            break;
        case ']':
            matchingChar = '[';
            break;
        default:
            matchingChar = '\0';
        }
        if (matchingChar == '\0') {
            docView.getToolkit().beep();
            return CommandResult.DONE;
        }

        final MarkManager markManager = docView.getMarkManager();
        TextLocation dot = markManager.getDot();

        charCount = 0;
        TextOffset match = null;
        if (dot.getOffset() > 0) {3
            match = processTextNode(dot.getTextNode(), dot.getOffset() - 1);
        }
        if (match == null) {4
            match = (TextOffset) Traversal.traverseBefore(dot.getTextNode(), 
                                                          this);
        }
        if (match == null) {
            docView.getToolkit().beep();
            return CommandResult.DONE;
        }

        Rectangle r1 = docView.modelToView(match.text, match.offset);5
        Rectangle r2 = docView.getVisibleRectangle();
        // r1 is null if the matching char is contained in a collapsed section.
        if (r1 == null || r1.height <= 0 || 
            r2.height <= 0 || !r1.intersects(r2)) {6
            char[] chars = match.text.getTextChars();

            int count = 1;
            int boundary = match.offset - 1;
            loop: while (boundary >= 0) {
                switch (chars[boundary]) {
                case '\n': case '\r':
                    break loop;
                }

                ++count;
                if (count == 80) {
                    break;
                }

                --boundary;
            }
            
            String line = new String(chars, boundary+1, match.offset-boundary);
            line = XMLText.collapseWhiteSpace(line);
            if (boundary >= 0 && count == 80) {
                line = "..." + line;
            }
            docView.showStatus("IT MATCHES '" + line + "'");

            return CommandResult.DONE;
         }

        final String id1 = "highlight.matchingChar" + highlightId;
        final String id2 = "highlight2.matchingChar" + highlightId;
        ++highlightId;

        markManager.beginMark();
        markManager.add(id2, match.text, match.offset+1);7
        markManager.add(id1, match.text, match.offset);
        markManager.endMark();

        Timer timer = 
            new Timer(500 /*ms*/, new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    markManager.beginMark();
                    markManager.remove(id1);
                    markManager.remove(id2);
                    markManager.endMark();
                 }
            });
        timer.setRepeats(false);
        timer.start();8

        return CommandResult.DONE;
    }

1

DocumentView.insertString inserts specified string before caret. If there is a text selection, insertString deletes the nodes specified by the selection and then inserts its argument.

2

Find character corresponding to inserted character.

3

If the caret is not located a the very beginning of a text node, search matching character before the caret.

4

If matching character has not been found, search it in nodes which precede the node containing the caret.

5

Matching character is found. ShowMatchingCharCmd doesn't need to highlight it if it is out of sight. DocumentView.modelToView and getVisibleRectangle are used to determine whether the matching character is out of sight.

6

When matching character is out of sight, ShowMatchingCharCmd simply prints a message containing the text line which precedes this character.

7

TextLocations with IDs "highlight.matchingCharN" and "highlight2.matchingCharN" are added around the matching character.

8

After 500ms, a Timer removes TextLocations with IDs "highlight.matchingCharN" and "highlight2.matchingCharN".

The following Traversal.Handler is used to find the matching '(' in nodes which precede the node where ')':

    private TextOffset processTextNode(TextNode textNode, int from) {
        char[] chars = textNode.getTextChars();

        if (from < 0) {
            from = chars.length - 1;
        }
        
        for (int i = from; i >= 0; --i) {
            char c = chars[i];

            if (c == insertedChar) {
                ++charCount;
            } else if (c == matchingChar) {
                --charCount;
                if (charCount == 0) {
                    return new TextOffset(textNode, i);
                }
            }
        }

        return null;
    }

    // ---------------------------------------
    // Traversal.Handler
    // ---------------------------------------

    public Object processText(Text text) {
        return processTextNode(text, -1);
    }

    public Object processPI(ProcessingInstruction pi) {
        return processTextNode(pi, -1);
    }

    public Object processComment(Comment comment) {
        return processTextNode(comment, -1);
    }

    public Object enterElement(Element element) {
        return null;
    }

    public Object leaveElement(Element element) {
        return null;
    }