001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.widgets;
003
004import java.awt.Color;
005import java.awt.ComponentOrientation;
006import java.awt.Font;
007import java.awt.FontMetrics;
008import java.awt.Graphics;
009import java.awt.Graphics2D;
010import java.awt.Insets;
011import java.awt.Point;
012import java.awt.RenderingHints;
013import java.awt.event.ComponentEvent;
014import java.awt.event.ComponentListener;
015import java.awt.event.FocusEvent;
016import java.awt.event.FocusListener;
017import java.beans.PropertyChangeEvent;
018import java.beans.PropertyChangeListener;
019
020import javax.swing.Icon;
021import javax.swing.JTextField;
022import javax.swing.RepaintManager;
023import javax.swing.JMenuItem;
024import javax.swing.JPopupMenu;
025import javax.swing.UIManager;
026import javax.swing.text.BadLocationException;
027import javax.swing.text.Document;
028
029import org.openstreetmap.josm.gui.MainApplication;
030import org.openstreetmap.josm.gui.MapFrame;
031import org.openstreetmap.josm.tools.Destroyable;
032import org.openstreetmap.josm.tools.Utils;
033
034/**
035 * Subclass of {@link JTextField} that:<ul>
036 * <li>adds a "native" context menu (undo/redo/cut/copy/paste/select all)</li>
037 * <li>adds an optional "hint" displayed when no text has been entered</li>
038 * <li>disables the global advanced key press detector when focused</li>
039 * <li>implements a workaround to <a href="https://bugs.openjdk.java.net/browse/JDK-6322854">JDK bug 6322854</a></li>
040 * </ul><br>This class must be used everywhere in core and plugins instead of {@code JTextField}.
041 * @since 5886
042 */
043public class JosmTextField extends JTextField implements Destroyable, ComponentListener, FocusListener, PropertyChangeListener {
044
045    private final PopupMenuLauncher launcher;
046    private String hint;
047    private Icon icon;
048    private Point iconPos;
049    private Insets originalMargin;
050    private OrientationAction orientationAction;
051
052    /**
053     * Constructs a new <code>JosmTextField</code> that uses the given text
054     * storage model and the given number of columns.
055     * This is the constructor through which the other constructors feed.
056     * If the document is <code>null</code>, a default model is created.
057     *
058     * @param doc  the text storage to use; if this is <code>null</code>,
059     *      a default will be provided by calling the
060     *      <code>createDefaultModel</code> method
061     * @param text  the initial string to display, or <code>null</code>
062     * @param columns  the number of columns to use to calculate
063     *   the preferred width &gt;= 0; if <code>columns</code>
064     *   is set to zero, the preferred width will be whatever
065     *   naturally results from the component implementation
066     * @throws IllegalArgumentException if <code>columns</code> &lt; 0
067     */
068    public JosmTextField(Document doc, String text, int columns) {
069        this(doc, text, columns, true);
070    }
071
072    /**
073     * Constructs a new <code>JosmTextField</code> that uses the given text
074     * storage model and the given number of columns.
075     * This is the constructor through which the other constructors feed.
076     * If the document is <code>null</code>, a default model is created.
077     *
078     * @param doc  the text storage to use; if this is <code>null</code>,
079     *      a default will be provided by calling the
080     *      <code>createDefaultModel</code> method
081     * @param text  the initial string to display, or <code>null</code>
082     * @param columns  the number of columns to use to calculate
083     *   the preferred width &gt;= 0; if <code>columns</code>
084     *   is set to zero, the preferred width will be whatever
085     *   naturally results from the component implementation
086     * @param undoRedo Enables or not Undo/Redo feature. Not recommended for table cell editors, unless each cell provides its own editor
087     * @throws IllegalArgumentException if <code>columns</code> &lt; 0
088     */
089    public JosmTextField(Document doc, String text, int columns, boolean undoRedo) {
090        super(doc, text, columns);
091        launcher = TextContextualPopupMenu.enableMenuFor(this, undoRedo);
092
093        // There seems to be a bug in Swing 8 that components with Bidi enabled are smaller than
094        // without. (eg. 23px vs 21px in height, maybe a font thing).  Usually Bidi starts disabled
095        // but gets enabled whenever RTL text is loaded.  To avoid trashing the layout we enable
096        // Bidi by default.  See also {@link #drawHint()}.
097        getDocument().putProperty("i18n", Boolean.TRUE);
098
099        // the menu and hotkey to change text orientation
100        orientationAction = new OrientationAction(this);
101        orientationAction.addPropertyChangeListener(this);
102        JPopupMenu menu = launcher.getMenu();
103        menu.addSeparator();
104        menu.add(new JMenuItem(orientationAction));
105        getInputMap().put(OrientationAction.getShortcutKey(), orientationAction);
106
107        // Fix minimum size when columns are specified
108        if (columns > 0) {
109            setMinimumSize(getPreferredSize());
110        }
111        addFocusListener(this);
112        addComponentListener(this);
113        // Workaround for Java bug 6322854
114        JosmPasswordField.workaroundJdkBug6322854(this);
115        originalMargin = getMargin();
116    }
117
118    /**
119     * Constructs a new <code>JosmTextField</code> initialized with the
120     * specified text and columns.  A default model is created.
121     *
122     * @param text the text to be displayed, or <code>null</code>
123     * @param columns  the number of columns to use to calculate
124     *   the preferred width; if columns is set to zero, the
125     *   preferred width will be whatever naturally results from
126     *   the component implementation
127     */
128    public JosmTextField(String text, int columns) {
129        this(null, text, columns);
130    }
131
132    /**
133     * Constructs a new <code>JosmTextField</code> initialized with the
134     * specified text. A default model is created and the number of
135     * columns is 0.
136     *
137     * @param text the text to be displayed, or <code>null</code>
138     */
139    public JosmTextField(String text) {
140        this(null, text, 0);
141    }
142
143    /**
144     * Constructs a new empty <code>JosmTextField</code> with the specified
145     * number of columns.
146     * A default model is created and the initial string is set to
147     * <code>null</code>.
148     *
149     * @param columns  the number of columns to use to calculate
150     *   the preferred width; if columns is set to zero, the
151     *   preferred width will be whatever naturally results from
152     *   the component implementation
153     */
154    public JosmTextField(int columns) {
155        this(null, null, columns);
156    }
157
158    /**
159     * Constructs a new <code>JosmTextField</code>.  A default model is created,
160     * the initial string is <code>null</code>,
161     * and the number of columns is set to 0.
162     */
163    public JosmTextField() {
164        this(null, null, 0);
165    }
166
167    /**
168     * Replies the hint displayed when no text has been entered.
169     * @return the hint
170     * @since 7505
171     */
172    public final String getHint() {
173        return hint;
174    }
175
176    /**
177     * Sets the hint to display when no text has been entered.
178     * @param hint the hint to set
179     * @return the old hint
180     * @since 18221 (signature)
181     */
182    public String setHint(String hint) {
183        String old = hint;
184        this.hint = hint;
185        return old;
186    }
187
188    /**
189     * Return true if the textfield should display the hint text.
190     *
191     * @return whether to display the hint text
192     * @since 18221
193     */
194    public boolean displayHint() {
195        return !Utils.isEmpty(hint) && getText().isEmpty() && !isFocusOwner();
196    }
197
198    /**
199     * Returns the icon to display
200     * @return the icon to display
201     * @since 17768
202     */
203    public Icon getIcon() {
204        return icon;
205    }
206
207    /**
208     * Sets the icon to display
209     * @param icon the icon to set
210     * @since 17768
211     */
212    public void setIcon(Icon icon) {
213        this.icon = icon;
214        if (icon == null) {
215            setMargin(originalMargin);
216        }
217        positionIcon();
218    }
219
220    private void positionIcon() {
221        if (icon != null) {
222            Insets margin = (Insets) originalMargin.clone();
223            int hGap = (getHeight() - icon.getIconHeight()) / 2;
224            if (getComponentOrientation() == ComponentOrientation.RIGHT_TO_LEFT) {
225                margin.right += icon.getIconWidth() + 2 * hGap;
226                iconPos = new Point(getWidth() - icon.getIconWidth() - hGap, hGap);
227            } else {
228                margin.left += icon.getIconWidth() + 2 * hGap;
229                iconPos = new Point(hGap, hGap);
230            }
231            setMargin(margin);
232        }
233    }
234
235    @Override
236    public void setComponentOrientation(ComponentOrientation o) {
237        if (o.isLeftToRight() != getComponentOrientation().isLeftToRight()) {
238            super.setComponentOrientation(o);
239            positionIcon();
240        }
241    }
242
243    /**
244     * Empties the internal undo manager.
245     * @since 14977
246     */
247    public final void discardAllUndoableEdits() {
248        launcher.discardAllUndoableEdits();
249    }
250
251    /**
252     * Returns the color for hint texts.
253     * @return the Color for hint texts
254     */
255    public static Color getHintTextColor() {
256        Color color = UIManager.getColor("TextField[Disabled].textForeground"); // Nimbus?
257        if (color == null)
258            color = UIManager.getColor("TextField.inactiveForeground");
259        if (color == null)
260            color = Color.GRAY;
261        return color;
262    }
263
264    /**
265     * Returns the font for hint texts.
266     * @return the font for hint texts
267     */
268    public static Font getHintFont() {
269        return UIManager.getFont("TextField.font");
270    }
271
272    @Override
273    public void paintComponent(Graphics g) {
274        super.paintComponent(g);
275        if (icon != null) {
276            icon.paintIcon(this, g, iconPos.x, iconPos.y);
277        }
278        if (displayHint()) {
279            // Logging.debug("drawing textfield hint: {0}", getHint());
280            drawHint(g);
281        }
282    }
283
284    /**
285     * Draws the hint text over the editor component.
286     *
287     * @param g the graphics context
288     */
289    public void drawHint(Graphics g) {
290        int x;
291        try {
292            x = modelToView(0).x;
293        } catch (BadLocationException exc) {
294            return; // can't happen
295        }
296        // Taken from http://stackoverflow.com/a/24571681/2257172
297        if (g instanceof Graphics2D) {
298            ((Graphics2D) g).setRenderingHint(
299                    RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
300        }
301        g.setColor(getHintTextColor());
302        g.setFont(getHintFont());
303        if (getComponentOrientation().isLeftToRight()) {
304            g.drawString(getHint(), x, getBaseline(getWidth(), getHeight()));
305        } else {
306            FontMetrics metrics = g.getFontMetrics(g.getFont());
307            int dx = metrics.stringWidth(getHint());
308            g.drawString(getHint(), x - dx, getBaseline(getWidth(), getHeight()));
309        }
310        // Needed to avoid endless repaint loop if we accidentally draw over the insets.  This may
311        // easily happen because a change in text orientation invalidates the textfield and
312        // following that the preferred size gets smaller. (Bug in Swing?)
313        RepaintManager.currentManager(this).markCompletelyClean(this);
314    }
315
316    @Override
317    public void focusGained(FocusEvent e) {
318        MapFrame map = MainApplication.getMap();
319        if (map != null) {
320            map.keyDetector.setEnabled(false);
321        }
322        if (e != null && e.getOppositeComponent() != null) {
323            // Select all characters when the change of focus occurs inside JOSM only.
324            // When switching from another application, it is annoying, see #13747
325            selectAll();
326        }
327        positionIcon();
328        repaint(); // get rid of hint
329    }
330
331    @Override
332    public void focusLost(FocusEvent e) {
333        MapFrame map = MainApplication.getMap();
334        if (map != null) {
335            map.keyDetector.setEnabled(true);
336        }
337        repaint(); // paint hint
338    }
339
340    @Override
341    public void destroy() {
342        removeFocusListener(this);
343        TextContextualPopupMenu.disableMenuFor(this, launcher);
344    }
345
346    @Override
347    public void componentResized(ComponentEvent e) {
348        positionIcon();
349    }
350
351    @Override
352    public void componentMoved(ComponentEvent e) {
353    }
354
355    @Override
356    public void componentShown(ComponentEvent e) {
357    }
358
359    @Override
360    public void componentHidden(ComponentEvent e) {
361    }
362
363    @Override
364    public void propertyChange(PropertyChangeEvent evt) {
365        // command from the menu / shortcut key
366        if ("orientationAction".equals(evt.getPropertyName())) {
367            setComponentOrientation((ComponentOrientation) evt.getNewValue());
368        }
369    }
370}