Chapter 4. Writing a command

Table of Contents

1. Compiling and running the code sample
2. What is a command?
2.1. The CommandBase base class.
3. A sample command: ConvertCaseCmd
3.1. First step: prepare
3.2. Second step: execute
3.3. Reporting errors from commands

Prerequisite: please first read Chapter 2, Writing a configuration file for XXE in XMLmind XML Editor - Configuration and Deployment.

1. Compiling and running the code sample

  • Execute ant (see build.xml) in the samples/commands/ directory to compile all sample commands: ConvertCaseCmd.java, ShowMatchingCharCmd.java, WrapElementCmd.java, MakeParagraphsCmd.java[4]. The build creates commands.jar.

  • Then register the commands with XXE by proceeding as following:

    1. Copy commands.incl and commands.jar to XXE_install_directory/addon/config/xhtml/.

    2. Include commands.incl in the XXE configuration file for XHTML which is XXE_install_directory/addon/config/xhtml/xhtml_strict.xxe.

    3. Restart XXE.

    4. Clear the Quick Start cache (OptionsPreferences, Advanced|Cached Data section in XMLmind XML Editor - Online Help), then restart XXE one more time. If you forget to do that, XXE will fail to see your extension.

  • Open any XHTML Strict document, for example samples/tests/in/sample1.html, then try the sample commands by using the following bindings:

    BindingCommand
    F4 uConverCaseCmd upper
    F4 lConverCaseCmd lower
    F4 cConverCaseCmd capital
    )ShowMatchingCharCmd )
    }ShowMatchingCharCmd }
    ]ShowMatchingCharCmd ]
    F4 tWrapElementCmd
    F4 wMakeParagraphsCmd p + paste after[implicitElement]

2. What is a command?

A command is some code which acts on the selection of a DocumentView by changing this selection and/or by modifying the Document displayed by the DocumentView.

The selection is specified by Marks attached to Document Nodes which are managed by a MarkManager. All the commands found in the com.xmlmind.xmledit.command package use the 4 standard marks:

dot

Its visual representation is called the caret (or the insertion cursor). It is a small vertical line which blinks (when the DocumentView has the keyboard focus).

mark

Text between dot and mark is selected. This text selection is painted over a pink background.

selected

The node to which this mark is attached is the selected node. It is displayed with a thin red line around it.

selected2

This mark can only be attached to a sibling of the selected node (therefore this mark is absent if the selected mark is absent). The node range marked by selected and selected2 is the selected node range. Each node in the selected range is displayed with a thin red line around it. This type of selection is really a generalization of single node selection.

Note that any of these marks may be absent.

Practically a command is an object which implements the Command interface:

public interface Command {
    boolean prepareCommand(Gadget gadget, String parameter, int x, int y);
    Object executeCommand(Gadget gadget, String parameter, int x, int y);
}

A command must be seen as a function which is divided in two steps: prepareCommand and executeCommand.

But these two steps really form a single function. The client code of a command cannot invoke executeCommand without invoking prepareCommand immediately before invoking executeCommand.

if (command.prepareCommand(docView, null, -1, -1)) {
    command.executeCommand(docView, null, -1, -1);
}

These two steps can be informally described as follows:

prepareCommand

Examines the context of invocation: the content of the Document displayed by the DocumentView, the Marks, the content of the clipboard, etc.

If the command cannot be executed in this context, this method returns false.

If the command can be executed in this context, this method returns true. In such case, most commands also prepare the job of following executeCommand step in the prepareCommand step.

PrepareCommand may be called quite often. Its execution must be quick.

executeCommand

Really does the job. Assumes that prepareCommand has just returned true.

A command is interactive. Its execution must be as quick as possible.

Generally a single instance of a command is created and used for all DocumentViews during the whole XML editing session. This means that it does not make sense for a command to keep a state between two invocations of executeCommand.

Instance variables of a command are like the local variables of a function. They are set by prepareCommand in order to be used in possibly following executeCommand and then they are completely forgotten.

Being logically a single function, the parameters of prepareCommand and executeCommand are the same:

gadget

Can always be casted to a DocumentView, the DocumentView on which the command is acting.

parameter

This string parametrizes the behavior of the command. Each command has its own syntax for its parameter string. Commands which cannot be parametrized must be passed null (null may be also accepted by some commands which can be parametrized). See Chapter 6, Commands written in the Java™ programming language in XMLmind XML Editor - Commands for a complete description of available commands and their parameters.

x, y

Some commands are designed to be bound to a mouse input. These arguments are the coordinates, in the DocumentView coordinate system, of mouse input which triggered the command. For the other type of commands, designed to be bound to a keyboard input or to be invoked from a menu, these coordinates are set to -1.

Almost all commands return null. The value returned by a command is only used by macro-commands which specify ``pipes'' of commands.

2.1. The CommandBase base class.

Instead of implementing interface Command, the commands used as examples in this guide extend the more typed, more convenient, CommandBase base class. This base class simply assumes that the Gadget parameter (see above) can always be casted to a DocumentView. This base class is thus trivially implemented as follows:

public abstract class CommandBase implements Command {
    public boolean prepareCommand(Gadget gadget, 
                                  String parameter, int x, int y) {
        return prepare((DocumentView) gadget, parameter, x, y);
    }

    public Object executeCommand(Gadget gadget, 
                                 String parameter, int x, int y) {
        return execute((DocumentView) gadget, parameter, x, y);
    }

    public abstract boolean prepare(DocumentView docView, 
                                    String parameter, int x, int y);

    public abstract Object execute(DocumentView docView, 
                                   String parameter, int x, int y);
}

3. A sample command: ConvertCaseCmd

This sample command is identical to the standard ConvertCase command. That is, it is not a simplified version for the purpose of this tutorial.

3.1. First step: prepare

public class ConvertCaseCmd extends CommandBase {
    private enum Op {
        LOWER,
        UPPER,
        CAPITAL
    }

    private Op op = null;
    private TextNode beginText = null;
    private int beginOffset = -1;
    private TextNode endText = null;
    private int endOffset = -1;

    public boolean prepare(DocumentView docView, 
                           String parameter, int x, int y) {
        beginText = null;1
        beginOffset = -1;
        endText = null;
        endOffset = -1;

        MarkManager markManager = docView.getMarkManager();2
        if (markManager == null)
            return false;

        if ("lower".equals(parameter))3
            op = Op.LOWER;
        else if ("upper".equals(parameter))
            op = Op.UPPER;
        else if ("capital".equals(parameter))
            op = Op.CAPITAL;
        else
            return false;

        NodeMark selected = markManager.getSelected();
        if (selected != null) {4
            Node selectedNode = selected.getNode();

            NodeMark selected2 = markManager.getSelected2();
            if (selected2 != null && selected2.getNode() != selectedNode)
                // Not implemented.
                return false;

            beginText = 
                (TextNode) Traversal.traverse(selectedNode, 
                                              Traversal.textNodeFinder);
            if (beginText == null)
                // Does not contain text.
                return false;

            beginOffset = 0;

            endText = 
              (TextNode) Traversal.traverseBackwards(selectedNode, 
                                                     Traversal.textNodeFinder);
            // endText cannot be null because beginText is not null.
            endOffset = endText.getTextLength();

            if (beginText == endText && beginOffset == endOffset)
                // Single empty text node: nothing to convert.
                return false;
            
        } else {
            TextLocation dot = markManager.getDot();
            if (dot == null)
                // Document does not contain text.
                return false;

            beginText = dot.getTextNode();
            beginOffset = dot.getOffset();

            TextLocation mark = markManager.getMark();
            if (mark != null) {5
                endText = mark.getTextNode();
                endOffset = mark.getOffset();
            } else {
                endText = beginText;
                endOffset = beginOffset;
            }

            if (beginText == endText) {
                if (beginOffset == endOffset) {6
                    // Not text selection: convert to end of word.

                    int count = beginText.getTextLength();
                    while (endOffset < count) {
                        if (XMLText.isXMLSpace(
                                            beginText.getTextChar(endOffset)))
                            break;

                        ++endOffset;
                    }

                    if (endOffset == beginOffset) 
                        // Nothing to convert (empty text node or caret at end
                        // of word).
                        return false;

                } else if (beginOffset > endOffset) {
                    int swap;
                    swap = beginOffset; 
                    beginOffset = endOffset; 
                    endOffset = swap;
                }
            }
        }

        return true;
    }
    ...

1

Initializes the instance variables of ConvertCaseCmd. If prepare is successful, these instance variables will be assigned new values to prepare the job of execute.

2

If a DocumentView has no MarkManager, this means that no Document is being displayed. In such case, ConvertCaseCmd cannot be executed and prepare immediately returns false.

3

Parameter is parsed and parsed value is assigned to instance variable conversion. Note that when parameter is syntactically incorrect, prepare just returns false without reporting an error message.

4

Case where nodes are selected.

Selection of a node range is not supported by ConvertCaseCmd. In such case, prepare simply returns false.

If selected node does not contain textual nodes or if selected element only contains a single empty textual node, prepare also returns false. (The case where selected element only contains multiple empty textual nodes is not detected.)

5

Case where there is a text selection.

6

Case where there is no text selection (or when mark is located at the same place than dot). Case conversion will be performed from dot position to end of word.

3.2. Second step: execute

    ...
    public Object execute(DocumentView docView, 
                          String parameter, int x, int y) {
        MarkManager markManager = docView.getMarkManager();
        markManager.beginMark();1

        boolean swapped = false;

        if (beginText == endText) {2
            convertCase(beginText, beginOffset, endOffset - beginOffset);
        } else {
            Document doc = docView.getDocument();
            doc.beginEdit();3

            TextRangeList rangeList = new TextRangeList();

            // Note that TextCollector handles the case where beginText is
            // after endText.
            TextCollector.Status status = 
                (new TextCollector()).collect(beginText, beginOffset, 
                                              endText, endOffset, 
                                              rangeList);4
            swapped = (status == TextCollector.Status.COLLECTED_AFTER_SWAP);

            TextRange[] list = rangeList.list;
            int count = rangeList.size;

            for (int i = 0; i < count; ++i) {5
                TextRange range = list[i];
                convertCase(range.text, range.offset, range.count);
            }

            doc.endEdit();
        }

        docView.describeUndo("Convert to " + parameter + "case");6

        /* Uncomment this to make this command repeatable.
        docView.addToCommandHistory(this, parameter, "ConvertCaseCmd");7
        */

        markManager.remove(Mark.SELECTED);8
        markManager.remove(Mark.SELECTED2);
        markManager.remove(Mark.MARK);
        if (swapped)
            markManager.getDot().moveTo(beginText, beginOffset);
        else
            markManager.getDot().moveTo(endText, endOffset);

        markManager.endMark();

        beginText = null;9
        beginOffset = -1;
        endText = null;
        endOffset = -1;

        return null;
    }

    private void convertCase(TextNode text, int offset, int count) {
        char[] chars = text.getTextChars();

        switch (op) {
        case LOWER:
            text.replaceText(offset, count, 
                             (new String(chars, offset, count)).toLowerCase());
            break;
        case UPPER:
            text.replaceText(offset, count, 
                             (new String(chars, offset, count)).toUpperCase());
            break;
        default:
            {
                char[] replacement = new char[count];
                System.arraycopy(chars, offset, replacement, 0, count);

                boolean firstCharOfWord = true;
                for (int i = 0; i < count; ++i) {
                    char c = replacement[i];

                    if (XMLText.isXMLSpace(c)) {
                        firstCharOfWord = true;
                    } else {
                        if (firstCharOfWord) {
                            firstCharOfWord = false;
                            replacement[i] = Character.toUpperCase(c);
                        } else {
                            replacement[i] = Character.toLowerCase(c);
                        }
                    }
                }

                text.replaceText(offset, count, new String(replacement));
            }
        }
    }
    ...

1

Using beginMark/endMark prevents the MarkManager to report several editing context changes when several marks are added, moved or removed in a batch.

When the editing context changes, some commands can no longer be executed and other commands which were disabled, now become executable. This is the definition and the sole purpose of the ContextChangeEvent.

An application such as XXE invokes the prepare methods of all commands used in its GUI and updates all its menus and toolbars each time the MarkManager of the document being edited reports a change for the editing context. This type of update is not quick and should occur when really needed.

2

Optimized case: case conversion from caret position to end of word. Note that the general case could have handled this simple case perfectly well.

3

BeginEdit/endEdit is used to mark a sequence of low-level editing operations as being a single high-level editing command .

The UndoManager uses this feature to undo the whole side effect of a command, whatever does this command, rather than to undo multiple low-level operations acting on Document nodes (by the way, such atomic operations generally mean nothing at all for the user of the XML editor).

4

TextCollector is one of the many available implementations of Traversal.Handler. It is used to collect the textual ranges (in the form of TextRange objects) contained in the area on which ConvertCaseCmd must operate.

5

General case: case conversion of all text ranges found by TextCollector.

6

The describeUndo convenience method of DocumentView is used to tag the new undo action created by the UndoManager in response to document modification by ConvertCaseCmd. Without this user-friendly label, the user of the XML editor would just see "Undo" as the “tool tip” of the Undo button of the main tool bar. Using this method allows the user to see "Undo Convert to uppercase".

7

The addToCommandHistory convenience method of DocumentView may be used to make the command repeatable.

Currently, all editing commands do not register themselves with the CommandHistory of the document being edited. Only the editing commands requesting the user to input a value (for example, the name of the element to be inserted) do so.

8

An editing command always updates the marks once it has finished its job. There is no general rule: a command must try to update the marks in a way which clearly indicates to the user what has been done.

9

It is generally a good idea to reinitialize the instance variables at the end of execute. Not keeping references to document nodes helps the Java™ garbage collector to do its job.

3.3. Reporting errors from commands

Commands are interactive. They can report errors in the execute step (never in the prepare step) using this type of code:

Alert.showError(docView.getPanel(), "Execution failed.");

Alert contains methods which make the use of JOptionPane simpler.

But most commands will prefer to display their messages in a less intrusive way, using the status bar of the hosting application:

ShowStatus.showStatus(docView.getPanel(), "Execution canceled.");

Applications which have a status bar must implement interface ShowStatus.StatusWindow. For example, this is the case for StyledEditor2:

public void showStatus(String message) {
    setMessage(message);
}


[4] These last 3 commands will be explained in next chapter.