001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Cursor;
007import java.awt.Dimension;
008import java.awt.FlowLayout;
009import java.awt.GridBagLayout;
010import java.awt.event.ActionEvent;
011import java.awt.event.MouseAdapter;
012import java.awt.event.MouseEvent;
013import java.util.Arrays;
014import java.util.Collections;
015import java.util.List;
016
017import javax.swing.BorderFactory;
018import javax.swing.ButtonGroup;
019import javax.swing.JCheckBox;
020import javax.swing.JLabel;
021import javax.swing.JOptionPane;
022import javax.swing.JPanel;
023import javax.swing.JRadioButton;
024import javax.swing.SwingUtilities;
025import javax.swing.text.BadLocationException;
026import javax.swing.text.Document;
027import javax.swing.text.JTextComponent;
028
029import org.openstreetmap.josm.data.osm.Filter;
030import org.openstreetmap.josm.data.osm.search.SearchCompiler;
031import org.openstreetmap.josm.data.osm.search.SearchMode;
032import org.openstreetmap.josm.data.osm.search.SearchParseError;
033import org.openstreetmap.josm.data.osm.search.SearchSetting;
034import org.openstreetmap.josm.gui.ExtendedDialog;
035import org.openstreetmap.josm.gui.MainApplication;
036import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSException;
037import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset;
038import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;
039import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
040import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
041import org.openstreetmap.josm.tools.GBC;
042import org.openstreetmap.josm.tools.JosmRuntimeException;
043import org.openstreetmap.josm.tools.Logging;
044import org.openstreetmap.josm.tools.Utils;
045
046/**
047 * Search dialog to find primitives by a wide range of search criteria.
048 * @since 14927 (extracted from {@code SearchAction})
049 */
050public class SearchDialog extends ExtendedDialog {
051
052    private final SearchSetting searchSettings;
053
054    private final HistoryComboBox hcbSearchString = new HistoryComboBox();
055    private final JCheckBox addOnToolbar = new JCheckBox(tr("add toolbar button"), false);
056
057    private JCheckBox caseSensitive;
058    private JCheckBox allElements;
059
060    private JRadioButton standardSearch;
061    private JRadioButton regexSearch;
062    private JRadioButton mapCSSSearch;
063
064    private JRadioButton replace;
065    private JRadioButton add;
066    private JRadioButton remove;
067    private JRadioButton inSelection;
068
069    /**
070     * Constructs a new {@code SearchDialog}.
071     * @param initialValues initial search settings
072     * @param searchExpressionHistory list of all texts that were recently used in the search
073     * @param expertMode expert mode
074     */
075    public SearchDialog(SearchSetting initialValues, List<String> searchExpressionHistory, boolean expertMode) {
076        super(MainApplication.getMainFrame(),
077                initialValues instanceof Filter ? tr("Filter") : tr("Search"),
078                initialValues instanceof Filter ? tr("Submit filter") : tr("Start Search"),
079                tr("Cancel"));
080        this.searchSettings = new SearchSetting(initialValues);
081        setButtonIcons("dialogs/search", "cancel");
082        configureContextsensitiveHelp("/Action/Search", true /* show help button */);
083        setContent(buildPanel(searchExpressionHistory, expertMode));
084    }
085
086    private JPanel buildPanel(List<String> searchExpressionHistory, boolean expertMode) {
087
088        // prepare the combo box with the search expressions
089        JLabel label = new JLabel(searchSettings instanceof Filter ? tr("Filter string:") : tr("Search string:"));
090
091        String tooltip = tr("Enter the search expression");
092        hcbSearchString.setText(searchSettings.text);
093        hcbSearchString.setToolTipText(tooltip);
094
095        // we have to reverse the history, because ComboBoxHistory will reverse it again in addElement()
096        Collections.reverse(searchExpressionHistory);
097        hcbSearchString.setPossibleItems(searchExpressionHistory);
098        hcbSearchString.setPreferredSize(new Dimension(40, hcbSearchString.getPreferredSize().height));
099        label.setLabelFor(hcbSearchString);
100
101        replace = new JRadioButton(tr("replace selection"), searchSettings.mode == SearchMode.replace);
102        add = new JRadioButton(tr("add to selection"), searchSettings.mode == SearchMode.add);
103        remove = new JRadioButton(tr("remove from selection"), searchSettings.mode == SearchMode.remove);
104        inSelection = new JRadioButton(tr("find in selection"), searchSettings.mode == SearchMode.in_selection);
105        ButtonGroup bg = new ButtonGroup();
106        bg.add(replace);
107        bg.add(add);
108        bg.add(remove);
109        bg.add(inSelection);
110
111        caseSensitive = new JCheckBox(tr("case sensitive"), searchSettings.caseSensitive);
112        allElements = new JCheckBox(tr("all objects"), searchSettings.allElements);
113        allElements.setToolTipText(tr("Also include incomplete and deleted objects in search."));
114
115        standardSearch = new JRadioButton(tr("standard"), !searchSettings.regexSearch && !searchSettings.mapCSSSearch);
116        regexSearch = new JRadioButton(tr("regular expression"), searchSettings.regexSearch);
117        mapCSSSearch = new JRadioButton(tr("MapCSS selector"), searchSettings.mapCSSSearch);
118        ButtonGroup bg2 = new ButtonGroup();
119        bg2.add(standardSearch);
120        bg2.add(regexSearch);
121        bg2.add(mapCSSSearch);
122
123        JPanel selectionSettings = new JPanel(new GridBagLayout());
124        selectionSettings.setBorder(BorderFactory.createTitledBorder(tr("Selection settings")));
125        selectionSettings.add(replace, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
126        selectionSettings.add(add, GBC.eol());
127        selectionSettings.add(remove, GBC.eol());
128        selectionSettings.add(inSelection, GBC.eop());
129
130        JPanel additionalSettings = new JPanel(new GridBagLayout());
131        additionalSettings.setBorder(BorderFactory.createTitledBorder(tr("Additional settings")));
132        additionalSettings.add(caseSensitive, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
133
134        JPanel left = new JPanel(new GridBagLayout());
135
136        left.add(selectionSettings, GBC.eol().fill(GBC.BOTH));
137        left.add(additionalSettings, GBC.eol().fill(GBC.BOTH));
138
139        if (expertMode) {
140            additionalSettings.add(allElements, GBC.eol());
141            additionalSettings.add(addOnToolbar, GBC.eop());
142
143            JPanel searchOptions = new JPanel(new GridBagLayout());
144            searchOptions.setBorder(BorderFactory.createTitledBorder(tr("Search syntax")));
145            searchOptions.add(standardSearch, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
146            searchOptions.add(regexSearch, GBC.eol());
147            searchOptions.add(mapCSSSearch, GBC.eol());
148
149            left.add(searchOptions, GBC.eol().fill(GBC.BOTH));
150        }
151
152        JPanel right = buildHintsSection(hcbSearchString, expertMode);
153        JPanel top = new JPanel(new GridBagLayout());
154        top.add(label, GBC.std().insets(0, 0, 5, 0));
155        top.add(hcbSearchString, GBC.eol().fill(GBC.HORIZONTAL));
156
157        JTextComponent editorComponent = hcbSearchString.getEditorComponent();
158        Document document = editorComponent.getDocument();
159
160        /*
161         * Setup the logic to validate the contents of the search text field which is executed
162         * every time the content of the field has changed. If the query is incorrect, then
163         * the text field is colored red.
164         */
165        document.addDocumentListener(new AbstractTextComponentValidator(editorComponent) {
166
167            @Override
168            public void validate() {
169                if (!isValid()) {
170                    feedbackInvalid(tr("Invalid search expression"));
171                } else {
172                    feedbackValid(tooltip);
173                }
174            }
175
176            @Override
177            public boolean isValid() {
178                try {
179                    SearchSetting ss = new SearchSetting();
180                    ss.text = hcbSearchString.getText();
181                    ss.caseSensitive = caseSensitive.isSelected();
182                    ss.regexSearch = regexSearch.isSelected();
183                    ss.mapCSSSearch = mapCSSSearch.isSelected();
184                    SearchCompiler.compile(ss);
185                    return true;
186                } catch (SearchParseError | MapCSSException e) {
187                    return false;
188                }
189            }
190        });
191
192        /*
193         * Setup the logic to append preset queries to the search text field according to
194         * selected preset by the user. Every query is of the form ' group/sub-group/.../presetName'
195         * if the corresponding group of the preset exists, otherwise it is simply ' presetName'.
196         */
197        TaggingPresetSelector selector = new TaggingPresetSelector(false, false);
198        selector.setBorder(BorderFactory.createTitledBorder(tr("Search by preset")));
199        selector.setDblClickListener(ev -> setPresetDblClickListener(selector, editorComponent));
200
201        JPanel p = new JPanel(new GridBagLayout());
202        p.add(top, GBC.eol().fill(GBC.HORIZONTAL).insets(5, 5, 5, 0));
203        p.add(left, GBC.std().anchor(GBC.NORTH).insets(5, 10, 10, 0).fill(GBC.VERTICAL));
204        p.add(right, GBC.std().fill(GBC.BOTH).insets(0, 10, 0, 0));
205        p.add(selector, GBC.eol().fill(GBC.BOTH).insets(0, 10, 0, 0));
206
207        return p;
208    }
209
210    @Override
211    protected void buttonAction(int buttonIndex, ActionEvent evt) {
212        if (buttonIndex == 0) {
213            try {
214                SearchSetting ss = new SearchSetting();
215                ss.text = hcbSearchString.getText();
216                ss.caseSensitive = caseSensitive.isSelected();
217                ss.regexSearch = regexSearch.isSelected();
218                ss.mapCSSSearch = mapCSSSearch.isSelected();
219                SearchCompiler.compile(ss);
220                super.buttonAction(buttonIndex, evt);
221            } catch (SearchParseError | MapCSSException e) {
222                Logging.debug(e);
223                JOptionPane.showMessageDialog(
224                        MainApplication.getMainFrame(),
225                        "<html>" + tr("Search expression is not valid: \n\n {0}",
226                                e.getMessage().replace("<html>", "").replace("</html>", "")).replace("\n", "<br>") +
227                        "</html>",
228                        tr("Invalid search expression"),
229                        JOptionPane.ERROR_MESSAGE);
230            }
231        } else {
232            super.buttonAction(buttonIndex, evt);
233        }
234    }
235
236    /**
237     * Returns the search settings chosen by user.
238     * @return the search settings chosen by user
239     */
240    public SearchSetting getSearchSettings() {
241        searchSettings.text = hcbSearchString.getText();
242        searchSettings.caseSensitive = caseSensitive.isSelected();
243        searchSettings.allElements = allElements.isSelected();
244        searchSettings.regexSearch = regexSearch.isSelected();
245        searchSettings.mapCSSSearch = mapCSSSearch.isSelected();
246
247        if (inSelection.isSelected()) {
248            searchSettings.mode = SearchMode.in_selection;
249        } else if (replace.isSelected()) {
250            searchSettings.mode = SearchMode.replace;
251        } else if (add.isSelected()) {
252            searchSettings.mode = SearchMode.add;
253        } else {
254            searchSettings.mode = SearchMode.remove;
255        }
256        return searchSettings;
257    }
258
259    /**
260     * Determines if the "add toolbar button" checkbox is selected.
261     * @return {@code true} if the "add toolbar button" checkbox is selected
262     */
263    public boolean isAddOnToolbar() {
264        return addOnToolbar.isSelected();
265    }
266
267    private static JPanel buildHintsSection(HistoryComboBox hcbSearchString, boolean expertMode) {
268        JPanel hintPanel = new JPanel(new GridBagLayout());
269        hintPanel.setBorder(BorderFactory.createTitledBorder(tr("Search hints")));
270
271        hintPanel.add(new SearchKeywordRow(hcbSearchString)
272                .addTitle(tr("basics"))
273                .addKeyword(tr("Baker Street"), null, tr("''Baker'' and ''Street'' in any key"))
274                .addKeyword(tr("\"Baker Street\""), "\"\"", tr("''Baker Street'' in any key"))
275                .addKeyword("<i>key</i>:<i>valuefragment</i>", null,
276                        tr("''valuefragment'' anywhere in ''key''"), "name:str matches name=Bakerstreet")
277                .addKeyword("-<i>key</i>:<i>valuefragment</i>", null, tr("''valuefragment'' nowhere in ''key''")),
278                GBC.eol());
279        hintPanel.add(new SearchKeywordRow(hcbSearchString)
280                .addKeyword("<i>key</i>", null, tr("matches if ''key'' exists"))
281                .addKeyword("<i>key</i>=<i>value</i>", null, tr("''key'' with exactly ''value''"))
282                .addKeyword("<i>key</i>=*", null, tr("''key'' with any value"))
283                .addKeyword("<i>key</i>=", null, tr("''key'' with empty value"))
284                .addKeyword("*=<i>value</i>", null, tr("''value'' in any key"))
285                .addKeyword("<i>key</i>><i>value</i>", null, tr("matches if ''key'' is greater than ''value'' (analogously, less than)"))
286                .addKeyword("\"key\"=\"value\"", "\"\"=\"\"",
287                        tr("to quote operators.<br>Within quoted strings the <b>\"</b> and <b>\\</b> characters need to be escaped " +
288                                "by a preceding <b>\\</b> (e.g. <b>\\\"</b> and <b>\\\\</b>)."),
289                        "\"addr:street\""),
290                GBC.eol().anchor(GBC.CENTER));
291        hintPanel.add(new SearchKeywordRow(hcbSearchString)
292                .addTitle(tr("combinators"))
293                .addKeyword("<i>expr</i> <i>expr</i>", null, tr("logical and (both expressions have to be satisfied)"))
294                .addKeyword("<i>expr</i> | <i>expr</i>", "| ", tr("logical or (at least one expression has to be satisfied)"))
295                .addKeyword("<i>expr</i> OR <i>expr</i>", "OR ", tr("logical or (at least one expression has to be satisfied)"))
296                .addKeyword("-<i>expr</i>", null, tr("logical not"))
297                .addKeyword("(<i>expr</i>)", "()", tr("use parenthesis to group expressions")),
298                GBC.eol());
299
300        if (expertMode) {
301            hintPanel.add(new SearchKeywordRow(hcbSearchString)
302                .addTitle(tr("objects"))
303                .addKeyword("type:node", "type:node ", tr("all nodes"))
304                .addKeyword("type:way", "type:way ", tr("all ways"))
305                .addKeyword("type:relation", "type:relation ", tr("all relations"))
306                .addKeyword("closed", "closed ", tr("all closed ways"))
307                .addKeyword("untagged", "untagged ", tr("object without useful tags")),
308                GBC.eol());
309            hintPanel.add(new SearchKeywordRow(hcbSearchString)
310                    .addKeyword("preset:\"Annotation/Address\"", "preset:\"Annotation/Address\"",
311                            tr("all objects that use the address preset"))
312                    .addKeyword("preset:\"Geography/Nature/*\"", "preset:\"Geography/Nature/*\"",
313                            tr("all objects that use any preset under the Geography/Nature group")),
314                    GBC.eol().anchor(GBC.CENTER));
315            hintPanel.add(new SearchKeywordRow(hcbSearchString)
316                .addTitle(tr("metadata"))
317                .addKeyword("user:", "user:", tr("objects changed by user", "user:anonymous"))
318                .addKeyword("id:", "id:", tr("objects with given ID"), "id:0 (new objects)")
319                .addKeyword("version:", "version:", tr("objects with given version"), "version:0 (objects without an assigned version)")
320                .addKeyword("changeset:", "changeset:", tr("objects with given changeset ID"),
321                        "changeset:0 (objects without an assigned changeset)")
322                .addKeyword("timestamp:", "timestamp:", tr("objects with last modification timestamp within range"), "timestamp:2012/",
323                        "timestamp:2008/2011-02-04T12"),
324                GBC.eol());
325            hintPanel.add(new SearchKeywordRow(hcbSearchString)
326                .addTitle(tr("properties"))
327                .addKeyword("nodes:<i>20-</i>", "nodes:", tr("ways with at least 20 nodes, or relations containing at least 20 nodes"))
328                .addKeyword("ways:<i>3-</i>", "ways:", tr("nodes with at least 3 referring ways, or relations containing at least 3 ways"))
329                .addKeyword("tags:<i>5-10</i>", "tags:", tr("objects having 5 to 10 tags"))
330                .addKeyword("role:", "role:", tr("objects with given role in a relation"))
331                .addKeyword("areasize:<i>-100</i>", "areasize:", tr("closed ways with an area of 100 m\u00b2"))
332                .addKeyword("waylength:<i>200-</i>", "waylength:", tr("ways with a length of 200 m or more")),
333                GBC.eol());
334            hintPanel.add(new SearchKeywordRow(hcbSearchString)
335                .addTitle(tr("state"))
336                .addKeyword("modified", "modified ", tr("all modified objects"))
337                .addKeyword("new", "new ", tr("all new objects"))
338                .addKeyword("selected", "selected ", tr("all selected objects"))
339                .addKeyword("incomplete", "incomplete ", tr("all incomplete objects"))
340                .addKeyword("deleted", "deleted ", tr("all deleted objects (checkbox <b>{0}</b> must be enabled)", tr("all objects"))),
341                GBC.eol());
342            hintPanel.add(new SearchKeywordRow(hcbSearchString)
343                .addTitle(tr("related objects"))
344                .addKeyword("child <i>expr</i>", "child ", tr("all children of objects matching the expression"), "child building")
345                .addKeyword("parent <i>expr</i>", "parent ", tr("all parents of objects matching the expression"), "parent bus_stop")
346                .addKeyword("hasRole:<i>stop</i>", "hasRole:", tr("relation containing a member of role <i>stop</i>"))
347                .addKeyword("role:<i>stop</i>", "role:", tr("objects being part of a relation as role <i>stop</i>"))
348                .addKeyword("nth:<i>7</i>", "nth:",
349                        tr("n-th member of relation and/or n-th node of way"), "nth:5 (child type:relation)", "nth:-1")
350                .addKeyword("nth%:<i>7</i>", "nth%:",
351                        tr("every n-th member of relation and/or every n-th node of way"), "nth%:100 (child waterway)"),
352                GBC.eol());
353            hintPanel.add(new SearchKeywordRow(hcbSearchString)
354                .addTitle(tr("view"))
355                .addKeyword("inview", "inview ", tr("objects in current view"))
356                .addKeyword("allinview", "allinview ", tr("objects (and all its way nodes / relation members) in current view"))
357                .addKeyword("indownloadedarea", "indownloadedarea ", tr("objects in downloaded area"))
358                .addKeyword("allindownloadedarea", "allindownloadedarea ",
359                        tr("objects (and all its way nodes / relation members) in downloaded area")),
360                GBC.eol());
361        }
362
363        return hintPanel;
364    }
365
366    /**
367     *
368     * @param selector Selector component that the user interacts with
369     * @param searchEditor Editor for search queries
370     */
371    private static void setPresetDblClickListener(TaggingPresetSelector selector, JTextComponent searchEditor) {
372        TaggingPreset selectedPreset = selector.getSelectedPresetAndUpdateClassification();
373
374        if (selectedPreset == null) {
375            return;
376        }
377
378        // Make sure that the focus is transferred to the search text field from the selector component
379        searchEditor.requestFocusInWindow();
380
381        // In order to make interaction with the search dialog simpler, we make sure that
382        // if autocompletion triggers and the text field is not in focus, the correct area is selected.
383        // We first request focus and then execute the selection logic.
384        // invokeLater allows us to defer the selection until waiting for focus.
385        SwingUtilities.invokeLater(() -> {
386            int textOffset = searchEditor.getCaretPosition();
387            String presetSearchQuery = " preset:" +
388                    "\"" + selectedPreset.getRawName() + "\"";
389            try {
390                searchEditor.getDocument().insertString(textOffset, presetSearchQuery, null);
391            } catch (BadLocationException e1) {
392                throw new JosmRuntimeException(e1.getMessage(), e1);
393            }
394        });
395    }
396
397    private static class SearchKeywordRow extends JPanel {
398
399        private final HistoryComboBox hcb;
400
401        SearchKeywordRow(HistoryComboBox hcb) {
402            super(new FlowLayout(FlowLayout.LEFT));
403            this.hcb = hcb;
404        }
405
406        /**
407         * Adds the title (prefix) label at the beginning of the row. Should be called only once.
408         * @param title English title
409         * @return {@code this} for easy chaining
410         */
411        public SearchKeywordRow addTitle(String title) {
412            add(new JLabel(tr("{0}: ", title)));
413            return this;
414        }
415
416        /**
417         * Adds an example keyword label at the end of the row. Can be called several times.
418         * @param displayText displayed HTML text
419         * @param insertText optional: if set, makes the label clickable, and {@code insertText} will be inserted in search string
420         * @param description optional: HTML text to be displayed in the tooltip
421         * @param examples optional: examples joined as HTML list in the tooltip
422         * @return {@code this} for easy chaining
423         */
424        public SearchKeywordRow addKeyword(String displayText, final String insertText, String description, String... examples) {
425            JLabel label = new JLabel("<html>"
426                    + "<style>td{border:1px solid gray; font-weight:normal;}</style>"
427                    + "<table><tr><td>" + displayText + "</td></tr></table></html>");
428            add(label);
429            if (description != null || examples.length > 0) {
430                label.setToolTipText("<html>"
431                        + description
432                        + (examples.length > 0 ? Utils.joinAsHtmlUnorderedList(Arrays.asList(examples)) : "")
433                        + "</html>");
434            }
435            if (insertText != null) {
436                label.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
437                label.addMouseListener(new MouseAdapter() {
438
439                    @Override
440                    public void mouseClicked(MouseEvent e) {
441                        JTextComponent tf = hcb.getEditorComponent();
442
443                        // Make sure that the focus is transferred to the search text field from the selector component
444                        if (!tf.hasFocus()) {
445                            tf.requestFocusInWindow();
446                        }
447
448                        // In order to make interaction with the search dialog simpler, we make sure that
449                        // if autocompletion triggers and the text field is not in focus, the correct area is selected.
450                        // We first request focus and then execute the selection logic.
451                        // invokeLater allows us to defer the selection until waiting for focus.
452                        SwingUtilities.invokeLater(() -> {
453                            try {
454                                tf.getDocument().insertString(tf.getCaretPosition(), ' ' + insertText, null);
455                            } catch (BadLocationException ex) {
456                                throw new JosmRuntimeException(ex.getMessage(), ex);
457                            }
458                        });
459                    }
460                });
461            }
462            return this;
463        }
464    }
465}