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}