MiguelMunoz
MiguelMunoz

Reputation: 4952

How do I get standard Caret behavior in Java Swing?

The Swing Text components, all of which extend JTextComponent, when the user selects some text, the JTextComponent class delegates the work of handling selected text to an instance of the Caret interface, called DefaultCaret. This interface not only shows the blinking caret, it keeps track of whatever text the user has selected, and response to mouse and keyboard events that changes the selection range.

The Swing DefaultCaret has most of the behavior of a standard caret, but some of my high-end users have pointed out what it doesn't do. Here are their issues:

(Note: These examples have trouble in Microsoft Edge because, when you select text, it puts up a "..." menu. In these examples, if you're using Edge, you need to type the escape key to get rid of that menu before going on to the next step.)

  1. If I double-click on a word, it should select the entire word. Java Swing's Caret does this. But, after doubling-clicking on a word, if I then try to extend the selection by shift-clicking on a second word, a standard caret extends the selection to include the entire second word. To illustrate, in the example text below, if I double-click after the o in clock, it selects the word clock, as it should. But if I then hold down the shift key and click after the o in wound, it should extend the selection all the way to the d. It does so on this page, but not in Java Swing. In Swing, it still extends the selection, but only to the location of the mouse-click.

    Example: The clock has been wound too tight.

  2. If I try to select a block of text by doing a full click, then drag, it should extend the selection by an entire word at a time as I drag through the text. (By "full click, then drag, I mean the following events done quickly on the same spot: mouseDown, mouseUp, mouseDown, mouseMove. This is like a double-click without the final mouse-up event.) You can try it on this page, and it will work, but it won't work in Java Swing. In Swing it will still extend the selection, but only to the position of the mouse.

  3. If I triple-click on some text, it will select the whole paragraph. (This doesn't work in Microsoft Edge, but it works in most browsers and editors) This doesn't work in Swing.

  4. If, after triple-clicking to select a paragraph, I then do a shift-click on a different paragraph, it should extend the selection to include the entire new paragraph.

  5. Like the full-click and drag example in item 2, if you do a full double-click and drag, it should first select the entire paragraph, then extend the selection one paragraph at a time. (Again, this doesn't work in Edge.) This behavior is less standard than the others, but it's still pretty common.

Some of my power users want to be able to use these in the Java Swing application that I maintain, and I want to give it to them. The whole point of my application is to speed up the processing of their data, and these changes will help with that. How can I do this?

Upvotes: 1

Views: 179

Answers (1)

MiguelMunoz
MiguelMunoz

Reputation: 4952

Here's a class I wrote to address these issues. It addresses issues 1 and 2. As far as triple-clicks go, it doesn't change the behavior of selecting the line instead the paragraph, but it addresses issue 4 on the selected line, consistent with the triple-click behavior. It doesn't address issue 5.

This class also has two factory methods that will do trouble-free installations of this caret into your text components.

import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeListener;
import javax.swing.SwingUtilities;
import javax.swing.text.BadLocationException;
import javax.swing.text.Caret;
import javax.swing.text.DefaultCaret;
import javax.swing.text.JTextComponent;
import javax.swing.text.Position;
import javax.swing.text.Utilities;

/**
 * <p>Implements Standard rules for extending the selection, consistent with the standard
 * behavior for extending the selection in all word processors, browsers, and other text
 * editing tools, on all platforms. Without this, Swing's behavior on extending the
 * selection is inconsistent with all other text editing tools.
 * </p><p>
 * Swing components don't handle selectByWord the way most UI text components do. If you
 * double-click on a word, they will all select the entire word. But if you do a 
 * click-and-drag, most components will (a) select the entire clicked word, and
 * (b) extend the selection a word at a time as the user drags across the text. And if
 * you double- click on a word and follow that with a shift-click, most components will
 * also extend the selection a word at a time.  Swing components handle a double-clicked
 * word the standard way, but do not handle click-and-drag or shift-click correctly. This
 * caret, which replaces the standard DefaultCaret, fixes this.</p>
 * <p>Created by IntelliJ IDEA.</p>
 * <p>Date: 2/23/20</p>
 * <p>Time: 10:58 PM</p>
 *
 * @author Miguel Mu\u00f1oz
 */
public class StandardCaret extends DefaultCaret {
  // In the event of a double-click, these are the positions of the low end and high end
  // of the word that was clicked.
  private int highMark;
  private int lowMark;
  private boolean selectingByWord = false; // true when the last selection was done by word.
  private boolean selectingByRow = false; // true when the last selection was done by paragraph. 

  /**
   * Instantiate an EnhancedCaret.
   */
  public StandardCaret() {
    super();
  }

  /**
   * <p>Install this Caret into a JTextComponent. Carets may not be shared among multiple
   * components.</p>
   * @param component The component to use the EnhancedCaret.
   */
  public void installInto(JTextComponent component) {
    replaceCaret(component, this);
  }

  /**
   * Install a new StandardCaret into a JTextComponent, such as a JTextField or
   * JTextArea, and starts the Caret blinking using the same blink-rate as the
   * previous Caret.
   *
   * @param component The JTextComponent subclass
   */
  public static void installStandardCaret(JTextComponent component) {
    replaceCaret(component, new StandardCaret());
  }

  /**
   * Installs the specified Caret into the JTextComponent, and starts the Caret blinking
   * using the same blink-rate as the previous Caret. This works with any Caret
   *
   * @param component The text component to get the new Caret
   * @param caret     The new Caret to install
   */
  public static void replaceCaret(final JTextComponent component, final Caret caret) {
    final Caret priorCaret = component.getCaret();
    int blinkRate = priorCaret.getBlinkRate();
    if (priorCaret instanceof PropertyChangeListener) {
      // For example, com.apple.laf.AquaCaret, the troublemaker, installs this listener
      // which doesn't get removed when the Caret gets uninstalled.
      component.removePropertyChangeListener((PropertyChangeListener) priorCaret);
    }
    component.setCaret(caret);
    caret.setBlinkRate(blinkRate); // Starts the new caret blinking.
  }

  @Override
  public void mousePressed(final MouseEvent e) {
    // if user is doing a shift-click. Construct a new MouseEvent that happened at one
    // end of the word, and send that to super.mousePressed().
    boolean isExtended = isExtendSelection(e);
    if (selectingByWord && isExtended) {
      MouseEvent alternateEvent = getRevisedMouseEvent(e, Utilities::getWordStart, Utilities::getWordEnd);
      super.mousePressed(alternateEvent);
    } else if (selectingByRow && isExtended) {
      MouseEvent alternateEvent = getRevisedMouseEvent(e, Utilities::getRowStart, Utilities::getRowEnd);
      super.mousePressed(alternateEvent);
    } else  {
      if (!isExtended) {
        int clickCount = e.getClickCount();
        selectingByWord = clickCount == 2;
        selectingByRow = clickCount == 3;
      }
      super.mousePressed(e); // let the system select the clicked word
      // save the low end of the selected word.
      lowMark = getMark();
      if (selectingByWord || selectingByRow) {
        // User did a double- or triple-click...
        // They've selected the whole word. Record the high end.
        highMark = getDot();
      } else {
        // Not a double-click.
        highMark = lowMark;
      }
    }
  }

  @Override
  public void mouseClicked(final MouseEvent e) {
    super.mouseClicked(e);
    if (selectingByRow) {
      int mark = getMark();
      int dot = getDot();
      lowMark = Math.min(mark, dot);
      highMark = Math.max(mark, dot);
    }
  }

  private MouseEvent getRevisedMouseEvent(final MouseEvent e, final BiTextFunction getStart, final BiTextFunction getEnd) {
    int newPos;
    int pos = getPos(e);
    final JTextComponent textComponent = getComponent();
    try {
      if (pos > highMark) {
        newPos = getEnd.loc(textComponent, pos);
        setDot(lowMark);
      } else if (pos < lowMark) {
        newPos = getStart.loc(textComponent, pos);
        setDot(highMark);
      } else {
        if (getMark() == lowMark) {
          newPos = getEnd.loc(textComponent, pos);
        } else {
          newPos = getStart.loc(textComponent, pos);
        }
        pos = -1; // ensure we make a new event
      }
    } catch (BadLocationException ex) {
      throw new IllegalStateException(ex);
    }
    MouseEvent alternateEvent;
    if (newPos == pos) {
      alternateEvent = e;
    } else {
      alternateEvent = makeNewEvent(e, newPos);
    }
    return alternateEvent;
  }

  private boolean isExtendSelection(MouseEvent e) {
    // We extend the selection when the shift is down but control is not. Other modifiers don't matter.
    int modifiers = e.getModifiersEx();
    int shiftAndControlDownMask = MouseEvent.SHIFT_DOWN_MASK | MouseEvent.CTRL_DOWN_MASK;
    return (modifiers & shiftAndControlDownMask) == MouseEvent.SHIFT_DOWN_MASK;
  }

  @Override
  public void setDot(final int dot, final Position.Bias dotBias) {
    super.setDot(dot, dotBias);
  }

  @Override
  public void mouseDragged(final MouseEvent e) {
    if (!selectingByWord && !selectingByRow) {
      super.mouseDragged(e);
    } else {
      BiTextFunction getStart;
      BiTextFunction getEnd;
      if (selectingByWord) {
        getStart = Utilities::getWordStart;
        getEnd = Utilities::getWordEnd;
      } else {
        // selecting by paragraph
        getStart = Utilities::getRowStart;
        getEnd = Utilities::getRowEnd;
      }
      // super.mouseDragged just calls moveDot() after getting the position. We can do
      // the same thing...
      // There's no "setMark()" method. You can set the mark by calling setDot(). It sets
      // both the mark and the dot to the same place. Then you can call moveDot() to put
      // the dot somewhere else.
      if ((!e.isConsumed()) && SwingUtilities.isLeftMouseButton(e)) {
        int pos = getPos(e);
        JTextComponent component = getComponent();
        try {
          if (pos > highMark) {
            int wordEnd = getEnd.loc(component, pos);
            setDot(lowMark);
            moveDot(wordEnd);
          } else if (pos < lowMark) {
            int wordStart = getStart.loc(component, pos);
            setDot(wordStart); // Sets the mark, too
            moveDot(highMark);
          } else {
            setDot(lowMark);
            moveDot(highMark);
          }
        } catch (BadLocationException ex) {
          ex.printStackTrace();
        }
      }
    }
  }

  private int getPos(final MouseEvent e) {
    JTextComponent component = getComponent();
    Point pt = new Point(e.getX(), e.getY());
    Position.Bias[] biasRet = new Position.Bias[1];
    return component.getUI().viewToModel(component, pt, biasRet);
  }
  
  private MouseEvent makeNewEvent(MouseEvent e, int pos) {
    JTextComponent component = getComponent();
    try {
      Rectangle rect = component.getUI().modelToView(component, pos);
      return new MouseEvent(
          component,
          e.getID(),
          e.getWhen(),
          e.getModifiers(),
          rect.x,
          rect.y,
          e.getClickCount(),
          e.isPopupTrigger(),
          e.getButton()
      );
    } catch (BadLocationException ev) {
      ev.printStackTrace();
      throw new IllegalStateException(ev);
    }
  }

// For eventual use by a "select paragraph" feature:
//  private static final char NEW_LINE = '\n';
//  private static int getParagraphStart(JTextComponent component, int position) {
//    return component.getText().substring(0, position).lastIndexOf(NEW_LINE);
//  }
//  
//  private static int getParagraphEnd(JTextComponent component, int position) {
//    return component.getText().indexOf(NEW_LINE, position);
//  }

  /**
   * Don't use this. I should throw CloneNotSupportedException, but it won't compile if I
   * do. Changing the access to protected doesn't help. If you don't believe me, try it
   * yourself.
   * @return A bad clone of this.
   */      
  @SuppressWarnings({"CloneReturnsClassType", "UseOfClone"})
  @Override
  public Object clone() {
    return super.clone();
  }
  
  @FunctionalInterface
  private interface BiTextFunction {
    int loc(JTextComponent component, int position) throws BadLocationException;
  }
}

Upvotes: 1

Related Questions