Skip to content

Instantly share code, notes, and snippets.

@eirikbakke
Created February 20, 2014 20:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save eirikbakke/9122230 to your computer and use it in GitHub Desktop.
Save eirikbakke/9122230 to your computer and use it in GitHub Desktop.
/* This file is based in part on:
* org.netbeans.editor.Utilities (with listed author Miloslav Metelka)
* The complete license headers for the original file can be found in the ExternalLicenses.txt
* file.
*
* This version by Eirik Bakke (ebakke@mit.edu).
*/
package com.sieuferd.upstream.localeditor;
import com.google.common.base.Preconditions;
import com.sieuferd.util.ThreadUtil;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Insets;
import java.awt.KeyboardFocusManager;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.prefs.Preferences;
import javax.swing.InputMap;
import javax.swing.JEditorPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.JViewport;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Caret;
import javax.swing.text.DefaultEditorKit;
import javax.swing.text.Document;
import javax.swing.text.EditorKit;
import javax.swing.text.Element;
import javax.swing.text.StyledDocument;
import org.netbeans.api.editor.settings.SimpleValueNames;
import org.netbeans.editor.EditorUI;
import org.netbeans.editor.Utilities;
import org.netbeans.lib.editor.util.swing.DocumentListenerPriority;
import org.netbeans.lib.editor.util.swing.DocumentUtilities;
import org.netbeans.modules.editor.indent.spi.CodeStylePreferences;
import org.openide.text.CloneableEditorSupport;
import org.openide.text.NbDocument;
/* TODO: Track this bug about spaces appearing at the beginning of wrap lines:
https://netbeans.org/bugzilla/show_bug.cgi?id=242113 */
/* TODO: Track this bug about the cursor being painted at the beginning of the following break line
when the logical location is at the end of a break line:
https://netbeans.org/bugzilla/show_bug.cgi?id=242115 */
// TODO: Make the cursor thinner, like a regular text field cursor.
/* TODO: Track this bug about the cursor not changing its visual location when the wrap width
changes, or implement a workaround if it doesn't get fixed:
https://netbeans.org/bugzilla/show_bug.cgi?id=241953 */
/* TODO: Consider showing line breaks. If I do, then I should hide the break indication in line
wrapping mode. See PRINTING_SPACE, PRINTING_TAB, LINE_CONTINUATION etc. in
DocumentViewOp; these can't be changed. Track this RFE:
https://netbeans.org/bugzilla/show_bug.cgi?id=213829 */
/* TODO: Sometimes the border gets painted in a different style until the next repaint, notably
after a big flicker that happens when a modal dialog box (such as the database connection
username/password dialog) happens to be shown just as NetBeans is sizing its main window.
I suspect this is an Aqua L&F-related bug. It's not much of a problem, but if it becomes
one, consider looking into it. */
// TODO: Move the keystroke configuration code out of this class, or document it.
/*
* An editor component suitable for use in a dialog box or toolbar. Features:
*
* <ul>
* <li>Full-fledged NetBeans editor; supports syntax coloring, code completion, etc.</li>
* <li>The editor can be a specific number of rows tall, and manages its own internal scroll
* pane.</li>
* <li>Includes a number of tweaks to improve how the scroll viewport moves with the caret.</li>
* <li>Automatically activates line-wrapping in multi-row mode.</li>
* <li>Deactivates certain irrelevant highlighting features, such as the text limit line and
* highlighting of the line on which the caret is located.</li>
* <li>Implementation avoids L&F-specific tweaks.</li>
* </ul>
*
*/
public final class LocalEditor extends JPanel {
private static final Logger LOG = Logger.getLogger(LocalEditor.class.getName());
private static final int WIDTH_CALCULATION_CUTOFF_LINES = 30;
private static final String NO_ACTION = "no-action";
private final JEditorPane editorPane = new JEditorPane() {
@Override
public Dimension getPreferredScrollableViewportSize() {
// Assume no insets, as margins and borders need to be handled in the enclosing JScrollPane.
return new Dimension(1, getRowHeight() * rows);
}
private int getRowHeight() {
final EditorUI editorUI = Utilities.getEditorUI(editorPane);
final int height;
if (editorUI != null) {
final int lineHeight = editorUI.getLineHeight();
height = lineHeight > 1 ? lineHeight :
// Same hack as used in Utilities.adjustScrollPaneSize.
(editorUI.getLineAscent() * 4) / 3;
} else {
// Similar to JTextArea.getPreferredSize()/getRowHeight().
height = editorPane.getFontMetrics(editorPane.getFont()).getHeight();
}
return height;
}
};
private final JScrollPane scrollPane = new JScrollPane(editorPane,
JScrollPane.VERTICAL_SCROLLBAR_NEVER, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
private final PropertyChangeListener lineHeightListener = new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent evt) {
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
editorPane.invalidate();
LocalEditor.this.revalidate();
}
});
}
};
private String contentType;
private int rows = 1;
public LocalEditor() {
initComponents();
}
public void setEditable(boolean editable) {
// TODO: Set the background color of the editor appropriately.
editorPane.setEditable(editable);
}
public boolean isEditable() {
return editorPane.isEditable();
}
public void setText(String text) {
editorPane.setText(text);
// TODO: Consider exposing getCaret() instead.
editorPane.getCaret().setDot(0);
}
public String getText() {
return editorPane.getText();
}
public void setVisibleRows(int rows) {
ThreadUtil.checkOnEDT();
/* I considered setting the verticalScrollBarPolicy to show vertical scrollbars whenever more
than one row is being displayed, however this causes layout bugs on MacOS. Besides, I will
eventually want the vertical scrollbar to line up with the button affordance for expanding the
editor. So the best way to introduce a vertical scrollbar will probably be to use an external
JScrollBar component that is not directly associated with the scrollPane. */
this.rows = rows;
editorPane.invalidate();
setWrapping(getVisibleRows() != 1);
revalidate();
}
public int getVisibleRows() {
return rows;
}
private void setWrapping(final boolean wrapping) {
if (wrapping == isWrapping())
return;
editorPane.putClientProperty(SimpleValueNames.TEXT_LINE_WRAP, wrapping ? "words" : "none");
// Allow the editor to resize itself before we apply viewport positioning tweaks.
revalidate();
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
try {
final Rectangle dotRect =
editorPane.getUI().modelToView(editorPane, editorPane.getCaret().getDot());
final Rectangle markRect =
editorPane.getUI().modelToView(editorPane, editorPane.getCaret().getMark());
// EditorUI.modelToView may sometimes return null, for instance during initialization.
/* Scroll to the mark first to have a better chance of getting both the selection and the
cursor dot in the viewport. */
if (markRect != null)
editorPane.scrollRectToVisible(markRect);
if (dotRect != null)
editorPane.scrollRectToVisible(dotRect);
} catch (BadLocationException e) {
LOG.log(Level.WARNING, "Unexpected exception while finding cursor view position", e);
}
if (wrapping) {
final JViewport viewport = scrollPane.getViewport();
viewport.setViewPosition(new Point(0, viewport.getViewPosition().y));
}
}
});
}
private boolean isWrapping() {
Object prop = editorPane.getClientProperty(SimpleValueNames.TEXT_LINE_WRAP);
return prop != null && !prop.equals("none");
}
public void setContentType(String contentType) {
ThreadUtil.checkOnEDT();
Preconditions.checkNotNull(contentType);
if (!contentType.equals(this.contentType)) {
if (this.contentType != null) {
final EditorUI editorUI = Utilities.getEditorUI(editorPane);
if (editorUI != null) {
editorUI.removePropertyChangeListener(
EditorUI.LINE_HEIGHT_CHANGED_PROP, lineHeightListener);
}
}
this.contentType = contentType;
final EditorKit editorKit = CloneableEditorSupport.getEditorKit(contentType);
if (editorKit.getClass().getName().startsWith(
"org.openide.text.CloneableEditorSupport$PlainEditorKit"))
{
/* PlainEditorKit has a line wrapping bug related to
https://netbeans.org/bugzilla/show_bug.cgi?id=241652 , and so should be avoided. */
LOG.warning("Got a PlainEditorKit; probably missing a runtime dependency on the " +
"org-netbeans-modules-editor-plain module");
}
/* The bug on https://netbeans.org/bugzilla/show_bug.cgi?id=110237 seems to suggest that it
might be necessary to call org.netbeans.modules.editor.lib2.EditorApiPackageAccessor.register
reflectively in order to make code completion work propertly; however, code completion seems
to work fine without it when I have tried it. I think the explicit registration is only needed
when using a JTextField; when using a JEditorPane, I think setEditorKit takes care of the
registration through BaseTextUI.installUI. */
editorPane.setEditorKit(editorKit);
{
final EditorUI editorUI = Utilities.getEditorUI(editorPane);
if (editorUI != null)
editorUI.addPropertyChangeListener(EditorUI.LINE_HEIGHT_CHANGED_PROP, lineHeightListener);
}
revalidate();
}
}
public String getContentType() {
return contentType;
}
private void updateDocumentPreferences() {
Preferences prefs = CodeStylePreferences.get(editorPane.getDocument()).getPreferences();
if (prefs != null)
prefs.putBoolean(SimpleValueNames.TEXT_LIMIT_LINE_VISIBLE, false);
}
private void initComponents() {
ThreadUtil.checkOnEDT();
/* This does not seem to do much; line wrapping works fine without it, and it is still necessary
do apply the scrolling adjustment in manageViewPositionListener. But if it does what I think it
does, namely avoiding the behavior mentioned in
https://netbeans.org/bugzilla/show_bug.cgi?id=241652 , then it might prevent bugs that I have
yet to discover. */
editorPane.putClientProperty("document-view-accurate-span", true);
setVisibleRows(1);
/* Another example of using this property can be seen in
org.netbeans.modules.mercurial.ui.annotate.TooltipWindow . */
editorPane.putClientProperty("HighlightsLayerExcludes",
"^org\\.netbeans\\.modules\\.editor\\.lib2\\.highlighting\\.CaretRowHighlighting$");
/* I do _not_ use the AsTextField client property here, as it is to over-reaching in its
modifications to editor behavior. Notably, it does not permit multi-line editing. Instead, I
have gone through all the places in the NetBeans source code that access the AsTextField
property and implemented only the specific tweaks I have found relevant.
editorPane.putClientProperty("AsTextField", Boolean.TRUE)
*/
// Do this after first setting client properties on editorPane.
setContentType("text/plain");
{
// TODO: Figure out the correct way to assign actions and keystrokes.
InputMap im = editorPane.getInputMap();
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.SHIFT_DOWN_MASK), NO_ACTION);
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), NO_ACTION);
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), NO_ACTION);
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_TAB, 0), NO_ACTION);
/* In-cell line breaks: Excel has different shortcuts on MacOS and Windows, whereas Google
Docs Spreadsheets seems to accept any combination of Ctrl/Alt/Command modifiers held down
while pressing Enter. Excel also seems to accept various other combinations. The "anything
goes" approach seems reasonable, so add every combination of the relevant modifiers below. */
for (int i = 0; i < 8; i++) {
int modifiers = 0;
// This will include the Command key on MacOS.
if ((i & 1) != 0)
modifiers |= Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
/* Despite this, Alt+Enter does not seem to work on MacOS--though Excel for MacOS does not
support this particular shortcut either. I suspect there might be legitimate reasons for
this, e.g. related to non-US keyboard layouts. */
if ((i & 2) != 0)
modifiers |= InputEvent.ALT_DOWN_MASK;
if ((i & 4) != 0)
modifiers |= InputEvent.CTRL_DOWN_MASK;
if (modifiers != 0) {
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, modifiers),
DefaultEditorKit.insertBreakAction);
}
}
}
// TODO: Is this necessary?
editorPane.addPropertyChangeListener(new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent evt) {
if ("editorKit".equals(evt.getPropertyName())) {
// TODO: Consider moving the update logic in setContentType() here instead.
editorPane.invalidate();
LocalEditor.this.revalidate();
}
}
});
DocumentUtilities.addDocumentListener(editorPane.getDocument(), manageViewPositionListener,
DocumentListenerPriority.AFTER_CARET_UPDATE);
editorPane.addPropertyChangeListener(new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent evt) {
if ("document".equals(evt.getPropertyName())) {
updateDocumentPreferences();
final Document oldDoc = (Document) evt.getOldValue();
if (oldDoc != null) {
DocumentUtilities.removeDocumentListener(oldDoc, manageViewPositionListener,
DocumentListenerPriority.AFTER_CARET_UPDATE);
}
final Document newDoc = (Document) evt.getNewValue();
if (newDoc != null) {
DocumentUtilities.addDocumentListener(newDoc, manageViewPositionListener,
DocumentListenerPriority.AFTER_CARET_UPDATE);
}
}
}
});
updateDocumentPreferences();
// TODO: Remove the following workaround when the mentioned bug is resolved.
/* Workaround for an editor bug where scrolling due to a caret position change happens before
the editor has had time to update its preferred size. This bug is not normally exposed in the
NetBeans editor because a "virtual space" equal to a third of the viewport's height is added to
the preferred size of the editor, and this space is normally taller than a single line. If the
virtual space is disabled using DISABLE_END_VIRTUAL_SPACE, the bug exhibits itself when lines
are added at the bottom of an edited file; the viewport will be one step behind the cursor.
See https://netbeans.org/bugzilla/show_bug.cgi?id=241898 . */
editorPane.getCaret().addChangeListener(new ChangeListener() {
private int lastDot, lastMark;
@Override
public void stateChanged(ChangeEvent e) {
final Caret caret = (Caret) e.getSource();
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
final int dot = caret.getDot();
final int mark = caret.getMark();
if (lastDot != dot || lastMark != mark) {
lastDot = dot;
lastMark = mark;
// Nudge the editor to scroll to the cursor again.
caret.setDot(mark);
if (dot != mark)
caret.moveDot(dot);
}
}
});
}
});
final JTextField referenceTextField = new JTextField("M");
// TODO: This is probably irrelevant for the formula editor; consider removing it.
editorPane.setFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS,
referenceTextField.getFocusTraversalKeys(KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS));
editorPane.setFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS,
referenceTextField.getFocusTraversalKeys(KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS));
/* Remove all margins from the editor component, instead rendering them in the enclosing
JScrollPane. This ensures that the viewport height matches the height of the desired number of
visible rows exactly. */
final Insets margin = referenceTextField.getMargin();
final Insets noMargin = new Insets(0, 0, 0, 0);
scrollPane.setOpaque(true);
scrollPane.setBackground(referenceTextField.getBackground());
editorPane.setMargin(noMargin);
if (!margin.equals(noMargin))
scrollPane.setBorder(new CompoundBorder(scrollPane.getBorder(), new EmptyBorder(margin)));
// TODO: Consider overriding setBorder to set the border of the visible scrollPane.
setLayout(new BorderLayout());
add(scrollPane, BorderLayout.CENTER);
}
/**
* Hack to make JEditorPane scrolling behave more like that of a JTextField, most notably when the
* viewport is being scrolled all the way to the end of a long line and a character is deleted.
*/
private final DocumentListener manageViewPositionListener = new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
changed();
}
@Override
public void removeUpdate(DocumentEvent e) {
changed();
}
@Override
public void changedUpdate(DocumentEvent e) {
changed();
}
private void changed() {
/* Push the viewport adjustment to the end of the event queue to avoid being "one step behind"
values we depend on to calculate the new viewport position. Doing this fixes real observable
bugs in the original code, including characters going past the viewport when typing characters
at the end of the string and the viewport leaving space for one extra character when deleting
characters at the end of the string. DocumentListenerPriority.AFTER_CARET_UPDATE seems not to
be late enough. */
SwingUtilities.invokeLater(new Runnable() {
@Override
public void run() {
/* TODO: If the bug below gets fixed, it might be possible to remove this whole
manageViewPositionListener business:
https://netbeans.org/bugzilla/show_bug.cgi?id=241652 */
if (!isWrapping())
scrollLeftToEndOfLongestLine();
}
});
}
/** @return -1 if the longest line width could not be computed */
private int getLongestLineWidth(Document doc) {
if (!(doc instanceof StyledDocument))
return -1;
/* See org.netbeans.modules.mercurial.ui.annotate.TooltipWindow for an example of how to
iterate over lines. */
/* "[T]he default root element of the document has child elements corresponding to all text
lines; and if using StyledDocument, [...] "paragraph" elements again correspond to text
lines."
http://bits.netbeans.org/dev/javadoc/org-openide-text/org/openide/text/doc-files/api.html
*/
final Element rootElement = NbDocument.findLineRootElement((StyledDocument) doc);
final int lineCount = rootElement.getElementCount();
if (lineCount > WIDTH_CALCULATION_CUTOFF_LINES)
return -1;
int ret = 0;
for (int i = 0; i < lineCount; i++) {
final Element lineElement = rootElement.getElement(i);
final Rectangle textRect;
try {
textRect = editorPane.getUI().modelToView(editorPane, lineElement.getEndOffset() - 1);
} catch (BadLocationException e) {
LOG.log(Level.WARNING, "Unexpected exception while adjusting viewport position", e);
return -1;
}
ret = Math.max(ret, textRect.x + textRect.width);
}
return ret;
}
private void scrollLeftToEndOfLongestLine() {
final JViewport viewport = scrollPane.getViewport();
final Point viewPosition = viewport.getViewPosition();
if (viewPosition.x > 0) {
final int textLength = getLongestLineWidth(editorPane.getDocument());
if (textLength < 0)
return;
final int viewLength = viewport.getExtentSize().width;
if (textLength < (viewPosition.x + viewLength)) {
// Align the end of the view with the end of the string.
viewPosition.x = Math.max(textLength - viewLength, 0);
viewport.setViewPosition(viewPosition);
}
}
}
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment