001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.tagging;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trc;
006
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.Font;
010import java.awt.GridBagLayout;
011import java.awt.GridLayout;
012import java.awt.event.ActionEvent;
013import java.awt.event.ActionListener;
014import java.io.File;
015import java.lang.reflect.Method;
016import java.lang.reflect.Modifier;
017import java.text.NumberFormat;
018import java.text.ParseException;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.EnumSet;
024import java.util.HashMap;
025import java.util.LinkedHashMap;
026import java.util.LinkedList;
027import java.util.List;
028import java.util.Map;
029import java.util.TreeSet;
030
031import javax.swing.ButtonGroup;
032import javax.swing.ImageIcon;
033import javax.swing.JButton;
034import javax.swing.JComponent;
035import javax.swing.JLabel;
036import javax.swing.JList;
037import javax.swing.JPanel;
038import javax.swing.JScrollPane;
039import javax.swing.JSeparator;
040import javax.swing.JToggleButton;
041import javax.swing.ListCellRenderer;
042import javax.swing.ListModel;
043
044import org.openstreetmap.josm.Main;
045import org.openstreetmap.josm.actions.search.SearchCompiler;
046import org.openstreetmap.josm.data.osm.OsmPrimitive;
047import org.openstreetmap.josm.data.osm.OsmUtils;
048import org.openstreetmap.josm.data.osm.Tag;
049import org.openstreetmap.josm.data.preferences.BooleanProperty;
050import org.openstreetmap.josm.gui.tagging.ac.AutoCompletingTextField;
051import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionItemPriority;
052import org.openstreetmap.josm.gui.tagging.ac.AutoCompletionList;
053import org.openstreetmap.josm.gui.widgets.JosmComboBox;
054import org.openstreetmap.josm.gui.widgets.JosmTextField;
055import org.openstreetmap.josm.gui.widgets.QuadStateCheckBox;
056import org.openstreetmap.josm.gui.widgets.UrlLabel;
057import org.openstreetmap.josm.tools.GBC;
058import org.openstreetmap.josm.tools.ImageProvider;
059import org.openstreetmap.josm.tools.Utils;
060import org.xml.sax.SAXException;
061
062/**
063 * Class that contains all subtypes of TaggingPresetItem, static supplementary data, types and methods
064 * @since 6068
065 */
066public final class TaggingPresetItems {
067    private TaggingPresetItems() {    }
068    
069    private static int auto_increment_selected = 0;
070    public static final String DIFFERENT = tr("<different>");
071
072    private static final BooleanProperty PROP_FILL_DEFAULT = new BooleanProperty("taggingpreset.fill-default-for-tagged-primitives", false);
073
074    // cache the parsing of types using a LRU cache (http://java-planet.blogspot.com/2005/08/how-to-set-up-simple-lru-cache-using.html)
075    private static final Map<String,EnumSet<TaggingPresetType>> typeCache =
076            new LinkedHashMap<String, EnumSet<TaggingPresetType>>(16, 1.1f, true);
077    
078    /**
079     * Last value of each key used in presets, used for prefilling corresponding fields
080     */
081    private static final Map<String,String> lastValue = new HashMap<String,String>();
082
083    public static class PresetListEntry {
084        public String value;
085        public String value_context;
086        public String display_value;
087        public String short_description;
088        public String icon;
089        public String icon_size;
090        public String locale_display_value;
091        public String locale_short_description;
092        private final File zipIcons = TaggingPresetReader.getZipIcons();
093
094        // Cached size (currently only for Combo) to speed up preset dialog initialization
095        private int prefferedWidth = -1;
096        private int prefferedHeight = -1;
097
098        public String getListDisplay() {
099            if (value.equals(DIFFERENT))
100                return "<b>"+DIFFERENT.replaceAll("<", "&lt;").replaceAll(">", "&gt;")+"</b>";
101
102            if (value.isEmpty())
103                return "&nbsp;";
104
105            final StringBuilder res = new StringBuilder("<b>");
106            res.append(getDisplayValue(true));
107            res.append("</b>");
108            if (getShortDescription(true) != null) {
109                // wrap in table to restrict the text width
110                res.append("<div style=\"width:300px; padding:0 0 5px 5px\">");
111                res.append(getShortDescription(true));
112                res.append("</div>");
113            }
114            return res.toString();
115        }
116
117        public ImageIcon getIcon() {
118            return icon == null ? null : loadImageIcon(icon, zipIcons, parseInteger(icon_size));
119        }
120
121        private Integer parseInteger(String str) {
122            if (str == null || str.isEmpty())
123                return null;
124            try {
125                return Integer.parseInt(str);
126            } catch (Exception e) {
127                //
128            }
129            return null;
130        }
131
132        public PresetListEntry() {
133        }
134
135        public PresetListEntry(String value) {
136            this.value = value;
137        }
138
139        public String getDisplayValue(boolean translated) {
140            return translated
141                    ? Utils.firstNonNull(locale_display_value, tr(display_value), trc(value_context, value))
142                            : Utils.firstNonNull(display_value, value);
143        }
144
145        public String getShortDescription(boolean translated) {
146            return translated
147                    ? Utils.firstNonNull(locale_short_description, tr(short_description))
148                            : short_description;
149        }
150
151        // toString is mainly used to initialize the Editor
152        @Override
153        public String toString() {
154            if (value.equals(DIFFERENT))
155                return DIFFERENT;
156            return getDisplayValue(true).replaceAll("<.*>", ""); // remove additional markup, e.g. <br>
157        }
158    }
159
160    public static class Role {
161        public EnumSet<TaggingPresetType> types;
162        public String key;
163        public String text;
164        public String text_context;
165        public String locale_text;
166        public SearchCompiler.Match memberExpression;
167
168        public boolean required = false;
169        public long count = 0;
170
171        public void setType(String types) throws SAXException {
172            this.types = getType(types);
173        }
174
175        public void setRequisite(String str) throws SAXException {
176            if("required".equals(str)) {
177                required = true;
178            } else if(!"optional".equals(str))
179                throw new SAXException(tr("Unknown requisite: {0}", str));
180        }
181
182        public void setMember_expression(String member_expression) throws SAXException {
183            try {
184                this.memberExpression = SearchCompiler.compile(member_expression, true, true);
185            } catch (SearchCompiler.ParseError ex) {
186                throw new SAXException(tr("Illegal member expression: {0}", ex.getMessage()), ex);
187            }
188        }
189
190        /* return either argument, the highest possible value or the lowest
191           allowed value */
192        public long getValidCount(long c)
193        {
194            if(count > 0 && !required)
195                return c != 0 ? count : 0;
196            else if(count > 0)
197                return count;
198            else if(!required)
199                return c != 0  ? c : 0;
200            else
201                return c != 0  ? c : 1;
202        }
203        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
204            String cstring;
205            if(count > 0 && !required) {
206                cstring = "0,"+count;
207            } else if(count > 0) {
208                cstring = String.valueOf(count);
209            } else if(!required) {
210                cstring = "0-...";
211            } else {
212                cstring = "1-...";
213            }
214            if(locale_text == null) {
215                if (text != null) {
216                    if(text_context != null) {
217                        locale_text = trc(text_context, fixPresetString(text));
218                    } else {
219                        locale_text = tr(fixPresetString(text));
220                    }
221                }
222            }
223            p.add(new JLabel(locale_text+":"), GBC.std().insets(0,0,10,0));
224            p.add(new JLabel(key), GBC.std().insets(0,0,10,0));
225            p.add(new JLabel(cstring), types == null ? GBC.eol() : GBC.std().insets(0,0,10,0));
226            if(types != null){
227                JPanel pp = new JPanel();
228                for(TaggingPresetType t : types) {
229                    pp.add(new JLabel(ImageProvider.get(t.getIconName())));
230                }
231                p.add(pp, GBC.eol());
232            }
233            return true;
234        }
235    }
236
237    /**
238     * Enum denoting how a match (see {@link Item#matches}) is performed.
239     */
240    public static enum MatchType {
241
242        /**
243         * Neutral, i.e., do not consider this item for matching.
244         */
245        NONE("none"),
246        /**
247         * Positive if key matches, neutral otherwise.
248         */
249        KEY("key"),
250        /**
251         * Positive if key matches, negative otherwise.
252         */
253        KEY_REQUIRED("key!"),
254        /**
255         * Positive if key and value matches, negative otherwise.
256         */
257        KEY_VALUE("keyvalue");
258
259        private final String value;
260
261        private MatchType(String value) {
262            this.value = value;
263        }
264
265        public String getValue() {
266            return value;
267        }
268
269        public static MatchType ofString(String type) {
270            for (MatchType i : EnumSet.allOf(MatchType.class)) {
271                if (i.getValue().equals(type))
272                    return i;
273            }
274            throw new IllegalArgumentException(type + " is not allowed");
275        }
276    }
277    
278    public static class Usage {
279        TreeSet<String> values;
280        boolean hadKeys = false;
281        boolean hadEmpty = false;
282        public boolean hasUniqueValue() {
283            return values.size() == 1 && !hadEmpty;
284        }
285
286        public boolean unused() {
287            return values.isEmpty();
288        }
289        public String getFirst() {
290            return values.first();
291        }
292
293        public boolean hadKeys() {
294            return hadKeys;
295        }
296    }
297
298    /**
299     * A tagging preset item displaying a localizable text.
300     * @since 6190
301     */
302    public static abstract class TaggingPresetTextItem extends TaggingPresetItem {
303
304        /**
305         * The text to display
306         */
307        public String text;
308        
309        /**
310         * The context used for translating {@link #text}
311         */
312        public String text_context;
313        
314        /**
315         * The localized version of {@link #text}
316         */
317        public String locale_text;
318
319        protected final void initializeLocaleText(String defaultText) {
320            if (locale_text == null) {
321                if (text == null) {
322                    locale_text = defaultText;
323                } else if (text_context != null) {
324                    locale_text = trc(text_context, fixPresetString(text));
325                } else {
326                    locale_text = tr(fixPresetString(text));
327                }
328            }
329        }
330
331        @Override
332        void addCommands(List<Tag> changedTags) {
333        }
334
335        protected String fieldsToString() {
336            return (text != null ? "text=" + text + ", " : "")
337                    + (text_context != null ? "text_context=" + text_context + ", " : "")
338                    + (locale_text != null ? "locale_text=" + locale_text : "");
339        }
340        
341        @Override
342        public String toString() {
343            return getClass().getSimpleName() + " [" + fieldsToString() + "]";
344        }
345    }
346
347    public static class Label extends TaggingPresetTextItem {
348
349        @Override
350        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
351            initializeLocaleText(null);
352            p.add(new JLabel(locale_text), GBC.eol());
353            return false;
354        }
355    }
356
357    public static class Link extends TaggingPresetTextItem {
358
359        /**
360         * The link to display
361         */
362        public String href;
363        
364        /**
365         * The localized version of {@link #href}
366         */
367        public String locale_href;
368
369        @Override
370        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
371            initializeLocaleText(tr("More information about this feature"));
372            String url = locale_href;
373            if (url == null) {
374                url = href;
375            }
376            if (url != null) {
377                p.add(new UrlLabel(url, locale_text, 2), GBC.eol().anchor(GBC.WEST));
378            }
379            return false;
380        }
381
382        @Override
383        protected String fieldsToString() {
384            return super.fieldsToString()
385                    + (href != null ? "href=" + href + ", " : "")
386                    + (locale_href != null ? "locale_href=" + locale_href + ", " : "");
387        }
388    }
389    
390    public static class Roles extends TaggingPresetItem {
391
392        public final List<Role> roles = new LinkedList<Role>();
393
394        @Override
395        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
396            p.add(new JLabel(" "), GBC.eol()); // space
397            if (!roles.isEmpty()) {
398                JPanel proles = new JPanel(new GridBagLayout());
399                proles.add(new JLabel(tr("Available roles")), GBC.std().insets(0, 0, 10, 0));
400                proles.add(new JLabel(tr("role")), GBC.std().insets(0, 0, 10, 0));
401                proles.add(new JLabel(tr("count")), GBC.std().insets(0, 0, 10, 0));
402                proles.add(new JLabel(tr("elements")), GBC.eol());
403                for (Role i : roles) {
404                    i.addToPanel(proles, sel);
405                }
406                p.add(proles, GBC.eol());
407            }
408            return false;
409        }
410
411        @Override
412        public void addCommands(List<Tag> changedTags) {
413        }
414    }
415
416    public static class Optional extends TaggingPresetTextItem {
417
418        // TODO: Draw a box around optional stuff
419        @Override
420        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
421            initializeLocaleText(tr("Optional Attributes:"));
422            p.add(new JLabel(" "), GBC.eol()); // space
423            p.add(new JLabel(locale_text), GBC.eol());
424            p.add(new JLabel(" "), GBC.eol()); // space
425            return false;
426        }
427    }
428
429    public static class Space extends TaggingPresetItem {
430
431        @Override
432        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
433            p.add(new JLabel(" "), GBC.eol()); // space
434            return false;
435        }
436
437        @Override
438        public void addCommands(List<Tag> changedTags) {
439        }
440
441        @Override
442        public String toString() {
443            return "Space";
444        }
445    }
446
447    /**
448     * Class used to represent a {@link JSeparator} inside tagging preset window.
449     * @since 6198
450     */
451    public static class ItemSeparator extends TaggingPresetItem {
452
453        @Override
454        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
455            p.add(new JSeparator(), GBC.eol().fill(GBC.HORIZONTAL).insets(0, 5, 0, 5));
456            return false;
457        }
458
459        @Override
460        public void addCommands(List<Tag> changedTags) {
461        }
462
463        @Override
464        public String toString() {
465            return "ItemSeparator";
466        }
467    }
468
469    public static abstract class KeyedItem extends TaggingPresetItem {
470
471        public String key;
472        public String text;
473        public String text_context;
474        public String match = getDefaultMatch().getValue();
475
476        public abstract MatchType getDefaultMatch();
477        public abstract Collection<String> getValues();
478
479        @Override
480        Boolean matches(Map<String, String> tags) {
481            switch (MatchType.ofString(match)) {
482            case NONE:
483                return null;
484            case KEY:
485                return tags.containsKey(key) ? true : null;
486            case KEY_REQUIRED:
487                return tags.containsKey(key);
488            case KEY_VALUE:
489                return tags.containsKey(key) && (getValues().contains(tags.get(key)));
490            default:
491                throw new IllegalStateException();
492            }
493        }
494        
495        @Override
496        public String toString() {
497            return "KeyedItem [key=" + key + ", text=" + text
498                    + ", text_context=" + text_context + ", match=" + match
499                    + "]";
500        }
501    }
502
503    public static class Key extends KeyedItem {
504
505        public String value;
506
507        @Override
508        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
509            return false;
510        }
511
512        @Override
513        public void addCommands(List<Tag> changedTags) {
514            changedTags.add(new Tag(key, value));
515        }
516
517        @Override
518        public MatchType getDefaultMatch() {
519            return MatchType.KEY_VALUE;
520        }
521
522        @Override
523        public Collection<String> getValues() {
524            return Collections.singleton(value);
525        }
526
527        @Override
528        public String toString() {
529            return "Key [key=" + key + ", value=" + value + ", text=" + text
530                    + ", text_context=" + text_context + ", match=" + match
531                    + "]";
532        }
533    }
534    
535    public static class Text extends KeyedItem {
536
537        public String locale_text;
538        public String default_;
539        public String originalValue;
540        public String use_last_as_default = "false";
541        public String auto_increment;
542        public String length;
543
544        private JComponent value;
545
546        @Override public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
547
548            // find out if our key is already used in the selection.
549            Usage usage = determineTextUsage(sel, key);
550            AutoCompletingTextField textField = new AutoCompletingTextField();
551            initAutoCompletionField(textField, key);
552            if (length != null && !length.isEmpty()) {
553                textField.setMaxChars(Integer.valueOf(length));
554            }
555            if (usage.unused()){
556                if (auto_increment_selected != 0  && auto_increment != null) {
557                    try {
558                        textField.setText(Integer.toString(Integer.parseInt(lastValue.get(key)) + auto_increment_selected));
559                    } catch (NumberFormatException ex) {
560                        // Ignore - cannot auto-increment if last was non-numeric
561                    }
562                }
563                else if (!usage.hadKeys() || PROP_FILL_DEFAULT.get() || "force".equals(use_last_as_default)) {
564                    // selected osm primitives are untagged or filling default values feature is enabled
565                    if (!"false".equals(use_last_as_default) && lastValue.containsKey(key)) {
566                        textField.setText(lastValue.get(key));
567                    } else {
568                        textField.setText(default_);
569                    }
570                } else {
571                    // selected osm primitives are tagged and filling default values feature is disabled
572                    textField.setText("");
573                }
574                value = textField;
575                originalValue = null;
576            } else if (usage.hasUniqueValue()) {
577                // all objects use the same value
578                textField.setText(usage.getFirst());
579                value = textField;
580                originalValue = usage.getFirst();
581            } else {
582                // the objects have different values
583                JosmComboBox comboBox = new JosmComboBox(usage.values.toArray());
584                comboBox.setEditable(true);
585                comboBox.setEditor(textField);
586                comboBox.getEditor().setItem(DIFFERENT);
587                value=comboBox;
588                originalValue = DIFFERENT;
589            }
590            if (locale_text == null) {
591                if (text != null) {
592                    if (text_context != null) {
593                        locale_text = trc(text_context, fixPresetString(text));
594                    } else {
595                        locale_text = tr(fixPresetString(text));
596                    }
597                }
598            }
599
600            // if there's an auto_increment setting, then wrap the text field
601            // into a panel, appending a number of buttons.
602            // auto_increment has a format like -2,-1,1,2
603            // the text box being the first component in the panel is relied
604            // on in a rather ugly fashion further down.
605            if (auto_increment != null) {
606                ButtonGroup bg = new ButtonGroup();
607                JPanel pnl = new JPanel(new GridBagLayout());
608                pnl.add(value, GBC.std().fill(GBC.HORIZONTAL));
609
610                // first, one button for each auto_increment value
611                for (final String ai : auto_increment.split(",")) {
612                    JToggleButton aibutton = new JToggleButton(ai);
613                    aibutton.setToolTipText(tr("Select auto-increment of {0} for this field", ai));
614                    aibutton.setMargin(new java.awt.Insets(0,0,0,0));
615                    aibutton.setFocusable(false);
616                    bg.add(aibutton);
617                    try {
618                        // TODO there must be a better way to parse a number like "+3" than this.
619                        final int buttonvalue = (NumberFormat.getIntegerInstance().parse(ai.replace("+", ""))).intValue();
620                        if (auto_increment_selected == buttonvalue) aibutton.setSelected(true);
621                        aibutton.addActionListener(new ActionListener() {
622                            @Override
623                            public void actionPerformed(ActionEvent e) {
624                                auto_increment_selected = buttonvalue;
625                            }
626                        });
627                        pnl.add(aibutton, GBC.std());
628                    } catch (ParseException x) {
629                        Main.error("Cannot parse auto-increment value of '" + ai + "' into an integer");
630                    }
631                }
632
633                // an invisible toggle button for "release" of the button group
634                final JToggleButton clearbutton = new JToggleButton("X");
635                clearbutton.setVisible(false);
636                clearbutton.setFocusable(false);
637                bg.add(clearbutton);
638                // and its visible counterpart. - this mechanism allows us to 
639                // have *no* button selected after the X is clicked, instead 
640                // of the X remaining selected
641                JButton releasebutton = new JButton("X");
642                releasebutton.setToolTipText(tr("Cancel auto-increment for this field"));
643                releasebutton.setMargin(new java.awt.Insets(0,0,0,0));
644                releasebutton.setFocusable(false);
645                releasebutton.addActionListener(new ActionListener() {
646                    @Override
647                    public void actionPerformed(ActionEvent e) {
648                        auto_increment_selected = 0;
649                        clearbutton.setSelected(true);
650                    }
651                });
652                pnl.add(releasebutton, GBC.eol());
653                value = pnl;
654            }
655            p.add(new JLabel(locale_text+":"), GBC.std().insets(0,0,10,0));
656            p.add(value, GBC.eol().fill(GBC.HORIZONTAL));
657            return true;
658        }
659
660        private static String getValue(Component comp) {
661            if (comp instanceof JosmComboBox) {
662                return ((JosmComboBox) comp).getEditor().getItem().toString();
663            } else if (comp instanceof JosmTextField) {
664                return ((JosmTextField) comp).getText();
665            } else if (comp instanceof JPanel) {
666                return getValue(((JPanel)comp).getComponent(0));
667            } else {
668                return null;
669            }
670        }
671        
672        @Override
673        public void addCommands(List<Tag> changedTags) {
674
675            // return if unchanged
676            String v = getValue(value);
677            if (v == null) {
678                Main.error("No 'last value' support for component " + value);
679                return;
680            }
681            
682            v = v.trim();
683
684            if (!"false".equals(use_last_as_default) || auto_increment != null) {
685                lastValue.put(key, v);
686            }
687            if (v.equals(originalValue) || (originalValue == null && v.length() == 0))
688                return;
689
690            changedTags.add(new Tag(key, v));
691        }
692
693        @Override
694        boolean requestFocusInWindow() {
695            return value.requestFocusInWindow();
696        }
697
698        @Override
699        public MatchType getDefaultMatch() {
700            return MatchType.NONE;
701        }
702
703        @Override
704        public Collection<String> getValues() {
705            if (default_ == null || default_.isEmpty())
706                return Collections.emptyList();
707            return Collections.singleton(default_);
708        }
709    }
710
711    /**
712     * A group of {@link Check}s.
713     * @since 6114
714     */
715    public static class CheckGroup extends TaggingPresetItem {
716        
717        /**
718         * Number of columns (positive integer)
719         */
720        public String columns;
721        
722        /**
723         * List of checkboxes
724         */
725        public final List<Check> checks = new LinkedList<Check>();
726
727        @Override
728        boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
729            Integer cols = Integer.valueOf(columns);
730            int rows = (int) Math.ceil(checks.size()/cols.doubleValue());
731            JPanel panel = new JPanel(new GridLayout(rows, cols));
732            
733            for (Check check : checks) {
734                check.addToPanel(panel, sel);
735            }
736            
737            p.add(panel, GBC.eol());
738            return false;
739        }
740
741        @Override
742        void addCommands(List<Tag> changedTags) {
743            for (Check check : checks) {
744                check.addCommands(changedTags);
745            }
746        }
747
748        @Override
749        public String toString() {
750            return "CheckGroup [columns=" + columns + "]";
751        }
752    }
753
754    public static class Check extends KeyedItem {
755
756        public String locale_text;
757        public String value_on = OsmUtils.trueval;
758        public String value_off = OsmUtils.falseval;
759        public boolean default_ = false; // only used for tagless objects
760
761        private QuadStateCheckBox check;
762        private QuadStateCheckBox.State initialState;
763        private boolean def;
764
765        @Override public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
766
767            // find out if our key is already used in the selection.
768            Usage usage = determineBooleanUsage(sel, key);
769            def = default_;
770
771            if(locale_text == null) {
772                if(text_context != null) {
773                    locale_text = trc(text_context, fixPresetString(text));
774                } else {
775                    locale_text = tr(fixPresetString(text));
776                }
777            }
778
779            String oneValue = null;
780            for (String s : usage.values) {
781                oneValue = s;
782            }
783            if (usage.values.size() < 2 && (oneValue == null || value_on.equals(oneValue) || value_off.equals(oneValue))) {
784                if (def && !PROP_FILL_DEFAULT.get()) {
785                    // default is set and filling default values feature is disabled - check if all primitives are untagged
786                    for (OsmPrimitive s : sel)
787                        if(s.hasKeys()) {
788                            def = false;
789                        }
790                }
791
792                // all selected objects share the same value which is either true or false or unset,
793                // we can display a standard check box.
794                initialState = value_on.equals(oneValue) ?
795                        QuadStateCheckBox.State.SELECTED :
796                            value_off.equals(oneValue) ?
797                                    QuadStateCheckBox.State.NOT_SELECTED :
798                                        def ? QuadStateCheckBox.State.SELECTED
799                                                : QuadStateCheckBox.State.UNSET;
800                check = new QuadStateCheckBox(locale_text, initialState,
801                        new QuadStateCheckBox.State[] {
802                        QuadStateCheckBox.State.SELECTED,
803                        QuadStateCheckBox.State.NOT_SELECTED,
804                        QuadStateCheckBox.State.UNSET });
805            } else {
806                def = false;
807                // the objects have different values, or one or more objects have something
808                // else than true/false. we display a quad-state check box
809                // in "partial" state.
810                initialState = QuadStateCheckBox.State.PARTIAL;
811                check = new QuadStateCheckBox(locale_text, QuadStateCheckBox.State.PARTIAL,
812                        new QuadStateCheckBox.State[] {
813                        QuadStateCheckBox.State.PARTIAL,
814                        QuadStateCheckBox.State.SELECTED,
815                        QuadStateCheckBox.State.NOT_SELECTED,
816                        QuadStateCheckBox.State.UNSET });
817            }
818            p.add(check, GBC.eol().fill(GBC.HORIZONTAL));
819            return true;
820        }
821
822        @Override public void addCommands(List<Tag> changedTags) {
823            // if the user hasn't changed anything, don't create a command.
824            if (check.getState() == initialState && !def) return;
825
826            // otherwise change things according to the selected value.
827            changedTags.add(new Tag(key,
828                    check.getState() == QuadStateCheckBox.State.SELECTED ? value_on :
829                        check.getState() == QuadStateCheckBox.State.NOT_SELECTED ? value_off :
830                            null));
831        }
832        @Override boolean requestFocusInWindow() {return check.requestFocusInWindow();}
833
834        @Override
835        public MatchType getDefaultMatch() {
836            return MatchType.NONE;
837        }
838
839        @Override
840        public Collection<String> getValues() {
841            return Arrays.asList(value_on, value_off);
842        }
843
844        @Override
845        public String toString() {
846            return "Check ["
847                    + (locale_text != null ? "locale_text=" + locale_text + ", " : "")
848                    + (value_on != null ? "value_on=" + value_on + ", " : "")
849                    + (value_off != null ? "value_off=" + value_off + ", " : "")
850                    + "default_=" + default_ + ", "
851                    + (check != null ? "check=" + check + ", " : "")
852                    + (initialState != null ? "initialState=" + initialState
853                            + ", " : "") + "def=" + def + "]";
854        }
855    }
856
857    public static abstract class ComboMultiSelect extends KeyedItem {
858
859        public String locale_text;
860        public String values;
861        public String values_from;
862        public String values_context;
863        public String display_values;
864        public String locale_display_values;
865        public String short_descriptions;
866        public String locale_short_descriptions;
867        public String default_;
868        public String delimiter = ";";
869        public String use_last_as_default = "false";
870        /** whether to use values for search via {@link TaggingPresetSelector} */
871        public String values_searchable = "false";
872
873        protected JComponent component;
874        protected final Map<String, PresetListEntry> lhm = new LinkedHashMap<String, PresetListEntry>();
875        private boolean initialized = false;
876        protected Usage usage;
877        protected Object originalValue;
878
879        protected abstract Object getSelectedItem();
880        protected abstract void addToPanelAnchor(JPanel p, String def);
881
882        protected char getDelChar() {
883            return delimiter.isEmpty() ? ';' : delimiter.charAt(0);
884        }
885
886        @Override
887        public Collection<String> getValues() {
888            initListEntries();
889            return lhm.keySet();
890        }
891
892        public Collection<String> getDisplayValues() {
893            initListEntries();
894            return Utils.transform(lhm.values(), new Utils.Function<PresetListEntry, String>() {
895                @Override
896                public String apply(PresetListEntry x) {
897                    return x.getDisplayValue(true);
898                }
899            });
900        }
901
902        @Override
903        public boolean addToPanel(JPanel p, Collection<OsmPrimitive> sel) {
904
905            initListEntries();
906
907            // find out if our key is already used in the selection.
908            usage = determineTextUsage(sel, key);
909            if (!usage.hasUniqueValue() && !usage.unused()) {
910                lhm.put(DIFFERENT, new PresetListEntry(DIFFERENT));
911            }
912
913            p.add(new JLabel(tr("{0}:", locale_text)), GBC.std().insets(0, 0, 10, 0));
914            addToPanelAnchor(p, default_);
915
916            return true;
917
918        }
919
920        private void initListEntries() {
921            if (initialized) {
922                lhm.remove(DIFFERENT); // possibly added in #addToPanel
923                return;
924            } else if (lhm.isEmpty()) {
925                initListEntriesFromAttributes();
926            } else {
927                if (values != null) {
928                    Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
929                            + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
930                            key, text, "values", "list_entry"));
931                }
932                if (display_values != null || locale_display_values != null) {
933                    Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
934                            + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
935                            key, text, "display_values", "list_entry"));
936                }
937                if (short_descriptions != null || locale_short_descriptions != null) {
938                    Main.warn(tr("Warning in tagging preset \"{0}-{1}\": "
939                            + "Ignoring ''{2}'' attribute as ''{3}'' elements are given.",
940                            key, text, "short_descriptions", "list_entry"));
941                }
942                for (PresetListEntry e : lhm.values()) {
943                    if (e.value_context == null) {
944                        e.value_context = values_context;
945                    }
946                }
947            }
948            if (locale_text == null) {
949                locale_text = trc(text_context, fixPresetString(text));
950            }
951            initialized = true;
952        }
953
954        private String[] initListEntriesFromAttributes() {
955            char delChar = getDelChar();
956
957            String[] value_array = null;
958            
959            if (values_from != null) {
960                String[] class_method = values_from.split("#");
961                if (class_method != null && class_method.length == 2) {
962                    try {
963                        Method method = Class.forName(class_method[0]).getMethod(class_method[1]);
964                        // Check method is public static String[] methodName()
965                        int mod = method.getModifiers();
966                        if (Modifier.isPublic(mod) && Modifier.isStatic(mod) 
967                                && method.getReturnType().equals(String[].class) && method.getParameterTypes().length == 0) {
968                            value_array = (String[]) method.invoke(null);
969                        } else {
970                            Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' is not \"{2}\"", key, text,
971                                    "public static String[] methodName()"));
972                        }
973                    } catch (Exception e) {
974                        Main.error(tr("Broken tagging preset \"{0}-{1}\" - Java method given in ''values_from'' threw {2} ({3})", key, text,
975                                e.getClass().getName(), e.getMessage()));
976                    }
977                }
978            }
979            
980            if (value_array == null) {
981                value_array = splitEscaped(delChar, values);
982            }
983
984            final String displ = Utils.firstNonNull(locale_display_values, display_values);
985            String[] display_array = displ == null ? value_array : splitEscaped(delChar, displ);
986
987            final String descr = Utils.firstNonNull(locale_short_descriptions, short_descriptions);
988            String[] short_descriptions_array = descr == null ? null : splitEscaped(delChar, descr);
989
990            if (display_array.length != value_array.length) {
991                Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''display_values'' must be the same as in ''values''", key, text));
992                display_array = value_array;
993            }
994
995            if (short_descriptions_array != null && short_descriptions_array.length != value_array.length) {
996                Main.error(tr("Broken tagging preset \"{0}-{1}\" - number of items in ''short_descriptions'' must be the same as in ''values''", key, text));
997                short_descriptions_array = null;
998            }
999
1000            for (int i = 0; i < value_array.length; i++) {
1001                final PresetListEntry e = new PresetListEntry(value_array[i]);
1002                e.locale_display_value = locale_display_values != null
1003                        ? display_array[i]
1004                                : trc(values_context, fixPresetString(display_array[i]));
1005                        if (short_descriptions_array != null) {
1006                            e.locale_short_description = locale_short_descriptions != null
1007                                    ? short_descriptions_array[i]
1008                                            : tr(fixPresetString(short_descriptions_array[i]));
1009                        }
1010                        lhm.put(value_array[i], e);
1011                        display_array[i] = e.getDisplayValue(true);
1012            }
1013
1014            return display_array;
1015        }
1016
1017        protected String getDisplayIfNull() {
1018            return null;
1019        }
1020
1021        @Override
1022        public void addCommands(List<Tag> changedTags) {
1023            Object obj = getSelectedItem();
1024            String display = (obj == null) ? null : obj.toString();
1025            String value = null;
1026            if (display == null) {
1027                display = getDisplayIfNull();
1028            }
1029
1030            if (display != null) {
1031                for (String val : lhm.keySet()) {
1032                    String k = lhm.get(val).toString();
1033                    if (k != null && k.equals(display)) {
1034                        value = val;
1035                        break;
1036                    }
1037                }
1038                if (value == null) {
1039                    value = display;
1040                }
1041            } else {
1042                value = "";
1043            }
1044            value = value.trim();
1045
1046            // no change if same as before
1047            if (originalValue == null) {
1048                if (value.length() == 0)
1049                    return;
1050            } else if (value.equals(originalValue.toString()))
1051                return;
1052
1053            if (!"false".equals(use_last_as_default)) {
1054                lastValue.put(key, value);
1055            }
1056            changedTags.add(new Tag(key, value));
1057        }
1058
1059        public void addListEntry(PresetListEntry e) {
1060            lhm.put(e.value, e);
1061        }
1062
1063        public void addListEntries(Collection<PresetListEntry> e) {
1064            for (PresetListEntry i : e) {
1065                addListEntry(i);
1066            }
1067        }
1068
1069        @Override
1070        boolean requestFocusInWindow() {
1071            return component.requestFocusInWindow();
1072        }
1073
1074        private static ListCellRenderer RENDERER = new ListCellRenderer() {
1075
1076            JLabel lbl = new JLabel();
1077
1078            @Override
1079            public Component getListCellRendererComponent(
1080                    JList list,
1081                    Object value,
1082                    int index,
1083                    boolean isSelected,
1084                    boolean cellHasFocus) {
1085                PresetListEntry item = (PresetListEntry) value;
1086
1087                // Only return cached size, item is not shown
1088                if (!list.isShowing() && item.prefferedWidth != -1 && item.prefferedHeight != -1) {
1089                    if (index == -1) {
1090                        lbl.setPreferredSize(new Dimension(item.prefferedWidth, 10));
1091                    } else {
1092                        lbl.setPreferredSize(new Dimension(item.prefferedWidth, item.prefferedHeight));
1093                    }
1094                    return lbl;
1095                }
1096
1097                lbl.setPreferredSize(null);
1098
1099
1100                if (isSelected) {
1101                    lbl.setBackground(list.getSelectionBackground());
1102                    lbl.setForeground(list.getSelectionForeground());
1103                } else {
1104                    lbl.setBackground(list.getBackground());
1105                    lbl.setForeground(list.getForeground());
1106                }
1107
1108                lbl.setOpaque(true);
1109                lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN));
1110                lbl.setText("<html>" + item.getListDisplay() + "</html>");
1111                lbl.setIcon(item.getIcon());
1112                lbl.setEnabled(list.isEnabled());
1113
1114                // Cache size
1115                item.prefferedWidth = lbl.getPreferredSize().width;
1116                item.prefferedHeight = lbl.getPreferredSize().height;
1117
1118                // We do not want the editor to have the maximum height of all
1119                // entries. Return a dummy with bogus height.
1120                if (index == -1) {
1121                    lbl.setPreferredSize(new Dimension(lbl.getPreferredSize().width, 10));
1122                }
1123                return lbl;
1124            }
1125        };
1126
1127
1128        protected ListCellRenderer getListCellRenderer() {
1129            return RENDERER;
1130        }
1131
1132        @Override
1133        public MatchType getDefaultMatch() {
1134            return MatchType.NONE;
1135        }
1136    }
1137
1138    public static class Combo extends ComboMultiSelect {
1139
1140        public boolean editable = true;
1141        protected JosmComboBox combo;
1142        public String length;
1143
1144        public Combo() {
1145            delimiter = ",";
1146        }
1147
1148        @Override
1149        protected void addToPanelAnchor(JPanel p, String def) {
1150            if (!usage.unused()) {
1151                for (String s : usage.values) {
1152                    if (!lhm.containsKey(s)) {
1153                        lhm.put(s, new PresetListEntry(s));
1154                    }
1155                }
1156            }
1157            if (def != null && !lhm.containsKey(def)) {
1158                lhm.put(def, new PresetListEntry(def));
1159            }
1160            lhm.put("", new PresetListEntry(""));
1161
1162            combo = new JosmComboBox(lhm.values().toArray());
1163            component = combo;
1164            combo.setRenderer(getListCellRenderer());
1165            combo.setEditable(editable);
1166            combo.reinitialize(lhm.values());
1167            AutoCompletingTextField tf = new AutoCompletingTextField();
1168            initAutoCompletionField(tf, key);
1169            if (length != null && !length.isEmpty()) {
1170                tf.setMaxChars(Integer.valueOf(length));
1171            }
1172            AutoCompletionList acList = tf.getAutoCompletionList();
1173            if (acList != null) {
1174                acList.add(getDisplayValues(), AutoCompletionItemPriority.IS_IN_STANDARD);
1175            }
1176            combo.setEditor(tf);
1177
1178            if (usage.hasUniqueValue()) {
1179                // all items have the same value (and there were no unset items)
1180                originalValue = lhm.get(usage.getFirst());
1181                combo.setSelectedItem(originalValue);
1182            } else if (def != null && usage.unused()) {
1183                // default is set and all items were unset
1184                if (!usage.hadKeys() || PROP_FILL_DEFAULT.get() || "force".equals(use_last_as_default)) {
1185                    // selected osm primitives are untagged or filling default feature is enabled
1186                    combo.setSelectedItem(lhm.get(def).getDisplayValue(true));
1187                } else {
1188                    // selected osm primitives are tagged and filling default feature is disabled
1189                    combo.setSelectedItem("");
1190                }
1191                originalValue = lhm.get(DIFFERENT);
1192            } else if (usage.unused()) {
1193                // all items were unset (and so is default)
1194                originalValue = lhm.get("");
1195                if ("force".equals(use_last_as_default) && lastValue.containsKey(key)) {
1196                    combo.setSelectedItem(lhm.get(lastValue.get(key)));
1197                } else {
1198                    combo.setSelectedItem(originalValue);
1199                }
1200            } else {
1201                originalValue = lhm.get(DIFFERENT);
1202                combo.setSelectedItem(originalValue);
1203            }
1204            p.add(combo, GBC.eol().fill(GBC.HORIZONTAL));
1205
1206        }
1207
1208        @Override
1209        protected Object getSelectedItem() {
1210            return combo.getSelectedItem();
1211
1212        }
1213
1214        @Override
1215        protected String getDisplayIfNull() {
1216            if (combo.isEditable())
1217                return combo.getEditor().getItem().toString();
1218            else
1219                return null;
1220        }
1221    }
1222    public static class MultiSelect extends ComboMultiSelect {
1223
1224        public long rows = -1;
1225        protected ConcatenatingJList list;
1226
1227        @Override
1228        protected void addToPanelAnchor(JPanel p, String def) {
1229            list = new ConcatenatingJList(delimiter, lhm.values().toArray());
1230            component = list;
1231            ListCellRenderer renderer = getListCellRenderer();
1232            list.setCellRenderer(renderer);
1233
1234            if (usage.hasUniqueValue() && !usage.unused()) {
1235                originalValue = usage.getFirst();
1236                list.setSelectedItem(originalValue);
1237            } else if (def != null && !usage.hadKeys() || PROP_FILL_DEFAULT.get() || "force".equals(use_last_as_default)) {
1238                originalValue = DIFFERENT;
1239                list.setSelectedItem(def);
1240            } else if (usage.unused()) {
1241                originalValue = null;
1242                list.setSelectedItem(originalValue);
1243            } else {
1244                originalValue = DIFFERENT;
1245                list.setSelectedItem(originalValue);
1246            }
1247
1248            JScrollPane sp = new JScrollPane(list);
1249            // if a number of rows has been specified in the preset,
1250            // modify preferred height of scroll pane to match that row count.
1251            if (rows != -1) {
1252                double height = renderer.getListCellRendererComponent(list,
1253                        new PresetListEntry("x"), 0, false, false).getPreferredSize().getHeight() * rows;
1254                sp.setPreferredSize(new Dimension((int) sp.getPreferredSize().getWidth(), (int) height));
1255            }
1256            p.add(sp, GBC.eol().fill(GBC.HORIZONTAL));
1257
1258
1259        }
1260
1261        @Override
1262        protected Object getSelectedItem() {
1263            return list.getSelectedItem();
1264        }
1265
1266        @Override
1267        public void addCommands(List<Tag> changedTags) {
1268            // Do not create any commands if list has been disabled because of an unknown value (fix #8605)
1269            if (list.isEnabled()) {
1270                super.addCommands(changedTags);
1271            }
1272        }
1273    }
1274
1275    /**
1276    * Class that allows list values to be assigned and retrieved as a comma-delimited
1277    * string (extracted from TaggingPreset)
1278    */
1279    private static class ConcatenatingJList extends JList {
1280        private String delimiter;
1281        public ConcatenatingJList(String del, Object[] o) {
1282            super(o);
1283            delimiter = del;
1284        }
1285
1286        public void setSelectedItem(Object o) {
1287            if (o == null) {
1288                clearSelection();
1289            } else {
1290                String s = o.toString();
1291                TreeSet<String> parts = new TreeSet<String>(Arrays.asList(s.split(delimiter)));
1292                ListModel lm = getModel();
1293                int[] intParts = new int[lm.getSize()];
1294                int j = 0;
1295                for (int i = 0; i < lm.getSize(); i++) {
1296                    if (parts.contains((((PresetListEntry)lm.getElementAt(i)).value))) {
1297                        intParts[j++]=i;
1298                    }
1299                }
1300                setSelectedIndices(Arrays.copyOf(intParts, j));
1301                // check if we have actually managed to represent the full
1302                // value with our presets. if not, cop out; we will not offer
1303                // a selection list that threatens to ruin the value.
1304                setEnabled(Utils.join(delimiter, parts).equals(getSelectedItem()));
1305            }
1306        }
1307
1308        public String getSelectedItem() {
1309            ListModel lm = getModel();
1310            int[] si = getSelectedIndices();
1311            StringBuilder builder = new StringBuilder();
1312            for (int i=0; i<si.length; i++) {
1313                if (i>0) {
1314                    builder.append(delimiter);
1315                }
1316                builder.append(((PresetListEntry)lm.getElementAt(si[i])).value);
1317            }
1318            return builder.toString();
1319        }
1320    }
1321    static public EnumSet<TaggingPresetType> getType(String types) throws SAXException {
1322        if (typeCache.containsKey(types))
1323            return typeCache.get(types);
1324        EnumSet<TaggingPresetType> result = EnumSet.noneOf(TaggingPresetType.class);
1325        for (String type : Arrays.asList(types.split(","))) {
1326            try {
1327                TaggingPresetType presetType = TaggingPresetType.fromString(type);
1328                result.add(presetType);
1329            } catch (IllegalArgumentException e) {
1330                throw new SAXException(tr("Unknown type: {0}", type));
1331            }
1332        }
1333        typeCache.put(types, result);
1334        return result;
1335    }
1336    
1337    static String fixPresetString(String s) {
1338        return s == null ? s : s.replaceAll("'","''");
1339    }
1340    
1341    /**
1342     * allow escaped comma in comma separated list:
1343     * "A\, B\, C,one\, two" --> ["A, B, C", "one, two"]
1344     * @param delimiter the delimiter, e.g. a comma. separates the entries and
1345     *      must be escaped within one entry
1346     * @param s the string
1347     */
1348    private static String[] splitEscaped(char delimiter, String s) {
1349        if (s == null)
1350            return new String[0];
1351        List<String> result = new ArrayList<String>();
1352        boolean backslash = false;
1353        StringBuilder item = new StringBuilder();
1354        for (int i=0; i<s.length(); i++) {
1355            char ch = s.charAt(i);
1356            if (backslash) {
1357                item.append(ch);
1358                backslash = false;
1359            } else if (ch == '\\') {
1360                backslash = true;
1361            } else if (ch == delimiter) {
1362                result.add(item.toString());
1363                item.setLength(0);
1364            } else {
1365                item.append(ch);
1366            }
1367        }
1368        if (item.length() > 0) {
1369            result.add(item.toString());
1370        }
1371        return result.toArray(new String[result.size()]);
1372    }
1373
1374    
1375    static Usage determineTextUsage(Collection<OsmPrimitive> sel, String key) {
1376        Usage returnValue = new Usage();
1377        returnValue.values = new TreeSet<String>();
1378        for (OsmPrimitive s : sel) {
1379            String v = s.get(key);
1380            if (v != null) {
1381                returnValue.values.add(v);
1382            } else {
1383                returnValue.hadEmpty = true;
1384            }
1385            if(s.hasKeys()) {
1386                returnValue.hadKeys = true;
1387            }
1388        }
1389        return returnValue;
1390    }
1391    static Usage determineBooleanUsage(Collection<OsmPrimitive> sel, String key) {
1392
1393        Usage returnValue = new Usage();
1394        returnValue.values = new TreeSet<String>();
1395        for (OsmPrimitive s : sel) {
1396            String booleanValue = OsmUtils.getNamedOsmBoolean(s.get(key));
1397            if (booleanValue != null) {
1398                returnValue.values.add(booleanValue);
1399            }
1400        }
1401        return returnValue;
1402    }
1403    protected static ImageIcon loadImageIcon(String iconName, File zipIcons, Integer maxSize) {
1404        final Collection<String> s = Main.pref.getCollection("taggingpreset.icon.sources", null);
1405        ImageProvider imgProv = new ImageProvider(iconName).setDirs(s).setId("presets").setArchive(zipIcons).setOptional(true);
1406        if (maxSize != null) {
1407            imgProv.setMaxSize(maxSize);
1408        }
1409        return imgProv.get();
1410    }
1411}