001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.tagging.presets.items;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.awt.Component;
008import java.awt.GridBagLayout;
009import java.awt.Insets;
010import java.text.NumberFormat;
011import java.text.ParseException;
012import java.util.ArrayList;
013import java.util.Collection;
014import java.util.Collections;
015import java.util.List;
016
017import javax.swing.AbstractButton;
018import javax.swing.BorderFactory;
019import javax.swing.ButtonGroup;
020import javax.swing.JButton;
021import javax.swing.JComponent;
022import javax.swing.JLabel;
023import javax.swing.JPanel;
024import javax.swing.JToggleButton;
025
026import org.openstreetmap.josm.data.osm.Tag;
027import org.openstreetmap.josm.data.tagging.ac.AutoCompletionItem;
028import org.openstreetmap.josm.gui.tagging.ac.AutoCompComboBoxEditor;
029import org.openstreetmap.josm.gui.tagging.ac.AutoCompComboBoxModel;
030import org.openstreetmap.josm.gui.tagging.ac.AutoCompTextField;
031import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionManager;
032import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetItem;
033import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetItemGuiSupport;
034import org.openstreetmap.josm.gui.util.DocumentAdapter;
035import org.openstreetmap.josm.gui.widgets.JosmComboBox;
036import org.openstreetmap.josm.gui.widgets.JosmTextField;
037import org.openstreetmap.josm.gui.widgets.OrientationAction;
038import org.openstreetmap.josm.tools.GBC;
039import org.openstreetmap.josm.tools.Logging;
040import org.openstreetmap.josm.tools.Utils;
041import org.openstreetmap.josm.tools.template_engine.ParseError;
042import org.openstreetmap.josm.tools.template_engine.TemplateEntry;
043import org.openstreetmap.josm.tools.template_engine.TemplateParser;
044import org.xml.sax.SAXException;
045
046/**
047 * Text field type.
048 */
049public class Text extends KeyedItem {
050
051    private static int auto_increment_selected; // NOSONAR
052
053    /** The default value for the item. If not specified, the current value of the key is chosen as default (if applicable). Defaults to "". */
054    public String default_; // NOSONAR
055    /** The original value */
056    public String originalValue; // NOSONAR
057    /** whether the last value is used as default. Using "force" enforces this behaviour also for already tagged objects. Default is "false".*/
058    public String use_last_as_default = "false"; // NOSONAR
059    /**
060     * May contain a comma separated list of integer increments or decrements, e.g. "-2,-1,+1,+2".
061     * A button will be shown next to the text field for each value, allowing the user to select auto-increment with the given stepping.
062     * Auto-increment only happens if the user selects it. There is also a button to deselect auto-increment.
063     * Default is no auto-increment. Mutually exclusive with {@link #use_last_as_default}.
064     */
065    public String auto_increment; // NOSONAR
066    /** The length of the text box (number of characters allowed). */
067    public short length; // NOSONAR
068    /** A comma separated list of alternative keys to use for autocompletion. */
069    public String alternative_autocomplete_keys; // NOSONAR
070
071    private JComponent value;
072    private transient TemplateEntry valueTemplate;
073
074    @Override
075    public boolean addToPanel(JPanel p, TaggingPresetItemGuiSupport support) {
076
077        AutoCompComboBoxModel<AutoCompletionItem> model = new AutoCompComboBoxModel<>();
078        List<String> keys = new ArrayList<>();
079        keys.add(key);
080        if (alternative_autocomplete_keys != null) {
081            for (String k : alternative_autocomplete_keys.split(",", -1)) {
082                keys.add(k);
083            }
084        }
085        getAllForKeys(keys).forEach(model::addElement);
086
087        AutoCompTextField<AutoCompletionItem> textField;
088        AutoCompComboBoxEditor<AutoCompletionItem> editor = null;
089
090        // find out if our key is already used in the selection.
091        Usage usage = determineTextUsage(support.getSelected(), key);
092
093        if (usage.unused() || usage.hasUniqueValue()) {
094            textField = new AutoCompTextField<>();
095        } else {
096            editor = new AutoCompComboBoxEditor<>();
097            textField = editor.getEditorComponent();
098        }
099        textField.setModel(model);
100        value = textField;
101
102        if (length > 0) {
103            textField.setMaxTextLength(length);
104        }
105        if (TaggingPresetItem.DISPLAY_KEYS_AS_HINT.get()) {
106            textField.setHint(key);
107        }
108        if (usage.unused()) {
109            if (auto_increment_selected != 0 && auto_increment != null) {
110                try {
111                    textField.setText(Integer.toString(Integer.parseInt(
112                            LAST_VALUES.get(key)) + auto_increment_selected));
113                } catch (NumberFormatException ex) {
114                    // Ignore - cannot auto-increment if last was non-numeric
115                    Logging.trace(ex);
116                }
117            } else if (!usage.hadKeys() || PROP_FILL_DEFAULT.get() || "force".equals(use_last_as_default)) {
118                // selected osm primitives are untagged or filling default values feature is enabled
119                if (!support.isPresetInitiallyMatches() && !"false".equals(use_last_as_default) && LAST_VALUES.containsKey(key)) {
120                    textField.setText(LAST_VALUES.get(key));
121                } else {
122                    textField.setText(default_);
123                }
124            } else {
125                // selected osm primitives are tagged and filling default values feature is disabled
126                textField.setText("");
127            }
128            value = textField;
129            originalValue = null;
130        } else if (usage.hasUniqueValue()) {
131            // all objects use the same value
132            textField.setText(usage.getFirst());
133            value = textField;
134            originalValue = usage.getFirst();
135        }
136        if (editor != null) {
137            // The selected primitives have different values for this key.   <b>Note:</b> this
138            // cannot be an AutoCompComboBox because the values in the dropdown are different from
139            // those we autocomplete on.
140            JosmComboBox<String> comboBox = new JosmComboBox<>();
141            comboBox.getModel().addAllElements(usage.map.keySet());
142            comboBox.setEditable(true);
143            comboBox.setEditor(editor);
144            comboBox.getEditor().setItem(DIFFERENT_I18N);
145            value = comboBox;
146            originalValue = DIFFERENT_I18N;
147        }
148        initializeLocaleText(null);
149
150        setupListeners(textField, support);
151
152        // if there's an auto_increment setting, then wrap the text field
153        // into a panel, appending a number of buttons.
154        // auto_increment has a format like -2,-1,1,2
155        // the text box being the first component in the panel is relied
156        // on in a rather ugly fashion further down.
157        if (auto_increment != null) {
158            ButtonGroup bg = new ButtonGroup();
159            JPanel pnl = new JPanel(new GridBagLayout());
160            pnl.add(value, GBC.std().fill(GBC.HORIZONTAL));
161
162            // first, one button for each auto_increment value
163            for (final String ai : auto_increment.split(",", -1)) {
164                JToggleButton aibutton = new JToggleButton(ai);
165                aibutton.setToolTipText(tr("Select auto-increment of {0} for this field", ai));
166                aibutton.setMargin(new Insets(0, 0, 0, 0));
167                aibutton.setFocusable(false);
168                saveHorizontalSpace(aibutton);
169                bg.add(aibutton);
170                try {
171                    // TODO there must be a better way to parse a number like "+3" than this.
172                    final int buttonvalue = NumberFormat.getIntegerInstance().parse(ai.replace("+", "")).intValue();
173                    if (auto_increment_selected == buttonvalue) aibutton.setSelected(true);
174                    aibutton.addActionListener(e -> auto_increment_selected = buttonvalue);
175                    pnl.add(aibutton, GBC.std());
176                } catch (ParseException ex) {
177                    Logging.error("Cannot parse auto-increment value of '" + ai + "' into an integer");
178                }
179            }
180
181            // an invisible toggle button for "release" of the button group
182            final JToggleButton clearbutton = new JToggleButton("X");
183            clearbutton.setVisible(false);
184            clearbutton.setFocusable(false);
185            bg.add(clearbutton);
186            // and its visible counterpart. - this mechanism allows us to
187            // have *no* button selected after the X is clicked, instead
188            // of the X remaining selected
189            JButton releasebutton = new JButton("X");
190            releasebutton.setToolTipText(tr("Cancel auto-increment for this field"));
191            releasebutton.setMargin(new Insets(0, 0, 0, 0));
192            releasebutton.setFocusable(false);
193            releasebutton.addActionListener(e -> {
194                auto_increment_selected = 0;
195                clearbutton.setSelected(true);
196            });
197            saveHorizontalSpace(releasebutton);
198            pnl.add(releasebutton, GBC.eol());
199            value = pnl;
200        }
201        final JLabel label = new JLabel(tr("{0}:", locale_text));
202        addIcon(label);
203        label.setToolTipText(getKeyTooltipText());
204        label.setComponentPopupMenu(getPopupMenu());
205        label.setLabelFor(value);
206        p.add(label, GBC.std().insets(0, 0, 10, 0));
207        p.add(value, GBC.eol().fill(GBC.HORIZONTAL));
208        label.applyComponentOrientation(support.getDefaultComponentOrientation());
209        value.setToolTipText(getKeyTooltipText());
210        value.applyComponentOrientation(OrientationAction.getNamelikeOrientation(key));
211        return true;
212    }
213
214    private static void saveHorizontalSpace(AbstractButton button) {
215        Insets insets = button.getBorder().getBorderInsets(button);
216        // Ensure the current look&feel does not waste horizontal space (as seen in Nimbus & Aqua)
217        if (insets != null && insets.left+insets.right > insets.top+insets.bottom) {
218            int min = Math.min(insets.top, insets.bottom);
219            button.setBorder(BorderFactory.createEmptyBorder(insets.top, min, insets.bottom, min));
220        }
221    }
222
223    private static String getValue(Component comp) {
224        if (comp instanceof JosmComboBox) {
225            return ((JosmComboBox<?>) comp).getEditorItemAsString();
226        } else if (comp instanceof JosmTextField) {
227            return ((JosmTextField) comp).getText();
228        } else if (comp instanceof JPanel) {
229            return getValue(((JPanel) comp).getComponent(0));
230        } else {
231            return null;
232        }
233    }
234
235    @Override
236    public void addCommands(List<Tag> changedTags) {
237
238        // return if unchanged
239        String v = getValue(value);
240        if (v == null) {
241            Logging.error("No 'last value' support for component " + value);
242            return;
243        }
244
245        v = Utils.removeWhiteSpaces(v);
246
247        if (!"false".equals(use_last_as_default) || auto_increment != null) {
248            LAST_VALUES.put(key, v);
249        }
250        if (v.equals(originalValue) || (originalValue == null && v.isEmpty()))
251            return;
252
253        changedTags.add(new Tag(key, v));
254        AutoCompletionManager.rememberUserInput(key, v, true);
255    }
256
257    @Override
258    public MatchType getDefaultMatch() {
259        return MatchType.NONE;
260    }
261
262    @Override
263    public Collection<String> getValues() {
264        if (Utils.isEmpty(default_))
265            return Collections.emptyList();
266        return Collections.singleton(default_);
267    }
268
269    /**
270     * Set the value template.
271     * @param pattern The value_template pattern.
272     * @throws SAXException If an error occured while parsing.
273     */
274    public void setValue_template(String pattern) throws SAXException { // NOPMD
275        try {
276            this.valueTemplate = new TemplateParser(pattern).parse();
277        } catch (ParseError e) {
278            Logging.error("Error while parsing " + pattern + ": " + e.getMessage());
279            throw new SAXException(e);
280        }
281    }
282
283    private void setupListeners(AutoCompTextField<AutoCompletionItem> textField, TaggingPresetItemGuiSupport support) {
284        // value_templates don't work well with multiple selected items because,
285        // as the command queue is currently implemented, we can only save
286        // the same value to all selected primitives, which is probably not
287        // what you want.
288        if (valueTemplate == null || support.getSelected().size() > 1) { // only fire on normal fields
289            textField.getDocument().addDocumentListener(DocumentAdapter.create(ignore ->
290                    support.fireItemValueModified(this, key, textField.getText())));
291        } else { // only listen on calculated fields
292            support.addListener((source, key, newValue) -> {
293                String valueTemplateText = valueTemplate.getText(support);
294                Logging.trace("Evaluating value_template {0} for key {1} from {2} with new value {3} => {4}",
295                        valueTemplate, key, source, newValue, valueTemplateText);
296                textField.setText(valueTemplateText);
297                if (originalValue != null && !originalValue.equals(valueTemplateText)) {
298                    textField.setForeground(Color.RED);
299                } else {
300                    textField.setForeground(Color.BLUE);
301                }
302            });
303        }
304    }
305}