Coverage for src/robotide/ui/keywordsearch.py: 67%
342 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-06 10:40 +0100
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-06 10:40 +0100
1# Copyright 2008-2015 Nokia Networks
2# Copyright 2016- Robot Framework Foundation
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
16import builtins
17import os.path
18from functools import (cmp_to_key)
20import wx
21from wx import Colour
22from wx.lib.mixins.listctrl import ListCtrlAutoWidthMixin
24from .. import utils
25from ..controller.filecontrollers import ResourceFileController, TestCaseFileController
26from ..pluginapi import Plugin
27from ..action import ActionInfo
28from ..publish.messages import RideOpenSuite, RideOpenResource, RideImportSetting, RideUserKeyword, RideNewProject
29from ..usages.UsageRunner import Usages
30from ..widgets import PopupMenuItem, ButtonWithHandler, Label, Font, HtmlWindow, ImageProvider, RIDEDialog
32_ = wx.GetTranslation # To keep linter/code analyser happy
33builtins.__dict__['_'] = wx.GetTranslation
35ALL_KEYWORDS = _('<all keywords>')
36ALL_USER_KEYWORDS = _('<all user keywords>')
37ALL_LIBRARY_KEYWORDS = _('<all library keywords>')
39SEARCH_KW = _('Search Keywords')
42class KeywordSearch(Plugin):
43 __doc__ = _("""A plugin for searching keywords based on name or documentation.""")
45 def __init__(self, app):
46 Plugin.__init__(self, app)
47 self.all_keywords = []
48 self._criteria = _SearchCriteria()
49 self.dirty = False
50 self._dialog = None
52 def enable(self):
53 action = ActionInfo(_('Tools'), SEARCH_KW, self.on_search,
54 shortcut='F5',
55 doc=_('Search keywords from libraries and resources'),
56 icon=ImageProvider().KW_SEARCH_ICON,
57 position=51)
58 self.register_action(action)
59 self.register_search_action(_('Search Keywords'), self.show_search_for, ImageProvider().KW_SEARCH_ICON)
60 self.subscribe(self.mark_dirty, RideOpenSuite, RideOpenResource,
61 RideImportSetting, RideUserKeyword, RideNewProject)
62 self._dialog = KeywordSearchDialog(self.frame, self)
63 self.tree.register_context_menu_hook(self._search_resource)
65 def on_search(self, event):
66 __ = event
67 self._dialog.show_search_with_criteria()
69 def mark_dirty(self, message):
70 _ = message
71 self.dirty = True
73 def have_keywords_changed(self):
74 if not self.dirty:
75 return False
76 self._update()
77 return True
79 def _update(self):
80 self.dirty = False
81 self.all_keywords = self.model.get_all_keywords()
83 def search(self, pattern, search_docs, source_filter):
84 self._criteria = _SearchCriteria(pattern, search_docs, source_filter)
85 return self._search()
87 def _search(self):
88 return [kw for kw in self.all_keywords if self._criteria.matches(kw)]
90 def _search_resource(self, item):
91 if isinstance(item, (TestCaseFileController, ResourceFileController)):
92 def _callable(arg=None):
93 self._show_resource(os.path.basename(item.source))
94 return [PopupMenuItem(SEARCH_KW,'Search Keywords', ccallable=_callable)]
95 return []
97 def _show_resource(self, resource):
98 self._dialog.show_search_with_criteria(source=resource)
100 def show_search_for(self, pattern):
101 self._dialog.show_search_with_criteria(pattern=pattern)
103 def disable(self):
104 self.unregister_actions()
105 self.unsubscribe_all()
108class _SearchCriteria(object):
110 def __init__(self, pattern='', search_docs=True, source_filter=ALL_KEYWORDS):
111 self._pattern = pattern 1aklmhinfg
112 self._search_docs = search_docs 1aklmhinfg
113 self._source_filter = source_filter 1aklmhinfg
115 def matches(self, kw):
116 if not self._matches_source_filter(kw): 1klmhinfg
117 return False 1mfg
118 if self._contains(kw.name, self._pattern): 1klhinfg
119 return True 1khinfg
120 return self._search_docs and self._contains(kw.doc, self._pattern) 1lhi
122 def _matches_source_filter(self, kw):
123 if self._source_filter == ALL_KEYWORDS: 1klmhinfg
124 return True 1klin
125 if self._source_filter == ALL_USER_KEYWORDS and kw.is_user_keyword(): 1mhfg
126 return True 1g
127 if self._source_filter == ALL_LIBRARY_KEYWORDS and kw.is_library_keyword(): 1mhfg
128 return True 1f
129 return self._source_filter == kw.source 1mhfg
131 @staticmethod
132 def _contains(string, pattern):
133 return utils.normalize(pattern) in utils.normalize(string) 1klhinfg
136class KeywordSearchDialog(RIDEDialog):
138 def __init__(self, parent, searcher):
139 RIDEDialog.__init__(self, title=SEARCH_KW, parent=parent, size=(650, 400),
140 style=wx.DEFAULT_FRAME_STYLE | wx.FRAME_FLOAT_ON_PARENT)
141 # set Left to Right direction (while we don't have localization)
142 self.SetLayoutDirection(wx.Layout_LeftToRight)
143 self.SetBackgroundColour(Colour(self.color_background))
144 self.SetForegroundColour(Colour(self.color_foreground))
145 self._plugin = searcher
146 self._create_components()
147 self._make_bindings()
148 self._sort_order = _SortOrder()
149 self._last_selected_kw = None
150 self.CenterOnParent()
152 def _create_components(self):
153 self.SetSizer(wx.BoxSizer(wx.VERTICAL))
154 self._add_search_control()
155 self._add_keyword_list()
156 self._add_keyword_details()
157 self.SetSize((700, 500))
159 def _add_search_control(self):
160 line1 = self._horizontal_sizer()
161 self._add_pattern_filter(line1)
162 self._add_doc_filter(line1)
163 self.Sizer.Add(line1, 0, wx.ALL, 3)
164 line2 = self._horizontal_sizer()
165 self._add_source_filter(line2)
166 self.Sizer.Add(line2, 0, wx.ALL, 3)
168 @staticmethod
169 def _horizontal_sizer():
170 return wx.BoxSizer(wx.HORIZONTAL)
172 def _add_pattern_filter(self, sizer):
173 sizer.Add(Label(self, label=_('Search term: ')))
174 self._search_control = wx.SearchCtrl(self, size=(200, -1), style=wx.TE_PROCESS_ENTER)
175 self._search_control.SetBackgroundColour(Colour(self.color_secondary_background))
176 self._search_control.SetForegroundColour(Colour(self.color_secondary_foreground))
177 sizer.Add(self._search_control)
179 def _add_doc_filter(self, sizer):
180 self._use_doc = wx.CheckBox(self, label=_('Search documentation'))
181 self._use_doc.SetValue(True)
182 sizer.Add(self._use_doc)
184 def _add_source_filter(self, sizer):
185 sizer.Add(Label(self, label=_('Source: ')))
186 self._source_filter = wx.ComboBox(self, value=ALL_KEYWORDS, size=(300, -1),
187 choices=self._get_sources(), style=wx.CB_READONLY)
188 self._source_filter.SetBackgroundColour(Colour(self.color_secondary_background))
189 self._source_filter.SetForegroundColour(Colour(self.color_secondary_foreground))
190 sizer.Add(self._source_filter)
192 def _get_sources(self):
193 sources = []
194 for kw in self._plugin.all_keywords: 194 ↛ 195line 194 didn't jump to line 195 because the loop on line 194 never started
195 if kw.source not in sources:
196 sources.append(kw.source)
197 return [ALL_KEYWORDS, ALL_USER_KEYWORDS, ALL_LIBRARY_KEYWORDS] + sorted(sources)
199 def _add_keyword_list(self):
200 self._list = _KeywordList(self, self._plugin)
201 self._list.SetSize(self.Size)
202 self._add_to_sizer(self._list)
204 def _add_keyword_details(self):
205 self._details = HtmlWindow(self)
206 self._add_to_sizer(self._details)
207 self._find_usages_button = ButtonWithHandler(self, _('Find Usages'), handler=self.on_find_usages)
208 self._find_usages_button.SetBackgroundColour(Colour(self.color_secondary_background))
209 self._find_usages_button.SetForegroundColour(Colour(self.color_secondary_foreground))
210 self.Sizer.Add(self._find_usages_button, 0, wx.ALL, 3)
211 self._results_text = wx.StaticText(self, -1, _('Results: %d') % 0)
212 self.Sizer.Add(self._results_text, 0, wx.ALL, 3)
214 def _add_to_sizer(self, component):
215 self.Sizer.Add(component, 1, wx.EXPAND | wx.ALL, 3)
217 def on_find_usages(self, event):
218 __ = event
219 Usages(self._plugin.model, self._plugin.tree.highlight, self._last_selected_kw.name,
220 self._last_selected_kw).show()
222 def _make_bindings(self):
223 self.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_item_selected, self._list)
224 self.Bind(wx.EVT_SEARCHCTRL_SEARCH_BTN, self.on_search,
225 self._search_control)
226 self.Bind(wx.EVT_TEXT_ENTER, self.on_search, self._search_control)
227 self.Bind(wx.EVT_ACTIVATE, self.on_activate)
228 self.Bind(wx.EVT_CLOSE, self.on_close)
229 self.Bind(wx.EVT_CHECKBOX, self.on_use_doc_change, self._use_doc)
230 self.Bind(wx.EVT_COMBOBOX, self.on_source_filter_change, self._source_filter)
231 self.Bind(wx.EVT_LIST_COL_CLICK, self.on_col_click)
233 def on_col_click(self, event):
234 col = event.GetColumn()
235 if self._sort_order.is_sortable_column(col):
236 self._sort_order.sort(col)
237 self._populate_search()
238 event.Skip()
240 def on_activate(self, event):
241 __ = event
242 if self._plugin.have_keywords_changed():
243 self._update_sources()
244 self._populate_search()
246 def on_use_doc_change(self, event):
247 __ = event
248 self._populate_search()
250 def on_search(self, event):
251 __ = event
252 self._sort_order.searched(self._get_search_text())
253 self._populate_search()
255 def on_source_filter_change(self, event):
256 self.on_use_doc_change(event)
258 def on_key(self, event):
259 # Needed for HtmlWindow callback
260 pass
262 def on_item_selected(self, event):
263 self._last_selected_kw = self._keywords[event.Index]
264 self._update_details()
266 def _update_sources(self):
267 selection = self._source_filter.GetValue()
268 self._source_filter.Clear()
269 for source in self._get_sources():
270 self._source_filter.Append(source)
271 self._source_filter.SetValue(selection)
272 if self._source_filter.GetValue() != selection:
273 self._source_filter.SetValue(ALL_KEYWORDS)
275 def on_close(self, event):
276 __ = event
277 self.Hide()
279 def _populate_search(self):
280 self._keywords = _KeywordData(self._plugin.search(*self._get_search_criteria()),
281 self._sort_order, self._get_search_text())
282 self._update_keyword_selection()
283 self._list.show_keywords(self._keywords, self._last_selected_kw)
284 self._results_text.SetLabel(_('Results: %d') % len(self._keywords))
285 self.Refresh()
287 def _get_search_criteria(self):
288 return self._get_search_text(), self._use_doc.GetValue(), self._source_filter.GetValue()
290 def _get_search_text(self):
291 return self._search_control.GetValue().lower()
293 def _update_keyword_selection(self):
294 if not self._keywords:
295 return
296 if self._last_selected_kw is None or self._last_selected_kw not in self._keywords:
297 self._last_selected_kw = self._keywords[0]
298 self._update_details()
300 def _update_details(self):
301 if self._last_selected_kw in self._keywords:
302 self._details.set_content(self._last_selected_kw.details)
303 self._find_usages_button.Enable()
304 else:
305 self._details.clear()
306 self._find_usages_button.Disable()
308 def show_search_with_criteria(self, pattern='', search_docs=True, source=ALL_KEYWORDS):
309 self._update_widgets(pattern, search_docs, source)
310 self._populate_search()
311 self._show()
312 self._search_control.SetFocus()
314 def _update_widgets(self, pattern, search_docs, source):
315 self._search_control.SetValue(pattern)
316 self._use_doc.SetValue(search_docs)
317 self._source_filter.SetValue(source)
319 def _show(self):
320 if not self.IsShown():
321 self.Show()
322 self.Raise()
325class _SortOrder(object):
327 def __init__(self):
328 self.sort_up = True 1ajdbec
329 self.column = 0 1ajdbec
330 self.default_order = False 1ajdbec
332 def searched(self, term):
333 self.__init__() 1b
334 if term: 334 ↛ exitline 334 didn't return from function 'searched' because the condition on line 334 was always true1b
335 self.default_order = True 1b
337 def swap_direction(self):
338 self.sort_up = not self.sort_up 1dc
340 @staticmethod
341 def is_sortable_column(col):
342 return col < 2
344 def sort(self, col):
345 if self._has_been_sorted_by(col): 1dec
346 self.swap_direction() 1dc
347 else:
348 self.sort_up = True 1ec
349 self.column = col 1ec
350 self.default_order = False 1dec
352 def _has_been_sorted_by(self, col):
353 return self.column == col and not self.default_order 1dec
356class _KeywordData(list):
357 headers = [_('Name'), _('Source'), _('Description')]
358 headers_attr = ['Name', 'Source', 'Description'] # Non-translated names
360 def __init__(self, keywords, sort_order, search_criteria=None):
361 self.extend(self._sort(keywords, sort_order, search_criteria)) 1jdbec
363 def _sort(self, keywords, sort_order, search_criteria=None):
364 if sort_order.default_order: 1jdbec
365 return self._sort_by_search(keywords, sort_order, search_criteria) 1b
366 return self._sort_by_attr(keywords, sort_order) 1jdec
368 def _sort_by_search(self, keywords, sort_order, search_criteria):
369 search_criteria = search_criteria.lower() 1b
370 starts_with = [kw for kw in keywords if kw.name.lower().startswith(search_criteria)] 1b
371 name_contains = [kw for kw in keywords if (search_criteria in kw.name.lower() 1b
372 and kw not in starts_with)]
373 doc_contains = [kw for kw in keywords if (search_criteria in kw.details.lower() 1b
374 and kw not in starts_with
375 and kw not in name_contains)]
376 result = [] 1b
377 for to_sort in (starts_with, name_contains, doc_contains): 1b
378 result.extend(self._sort_by_attr(to_sort, sort_order)) 1b
379 return result 1b
381 def _sort_by_attr(self, keywords, sort_order):
382 return sorted(keywords, key=cmp_to_key(self._get_comparator_for( 1jdbec
383 self.headers_attr[sort_order.column].lower())),
384 reverse=not sort_order.sort_up)
386 @staticmethod
387 def m_cmp(a, b):
388 return (a > b) - (a < b) 1jdbec
390 def _get_comparator_for(self, atrr_name):
391 return lambda kw, kw2: self.m_cmp(self._value_lowerer(kw, atrr_name), 1jdbec
392 self._value_lowerer(kw2, atrr_name))
394 @staticmethod
395 def _value_lowerer(kw, attr_name):
396 return getattr(kw, attr_name).lower() 1jdbec
399class _KeywordList(wx.ListCtrl, ListCtrlAutoWidthMixin):
401 def __init__(self, parent, plugin):
402 style = wx.LC_REPORT | wx.NO_BORDER | wx.LC_SINGLE_SEL | wx.LC_HRULES | wx.LC_VIRTUAL
403 wx.ListCtrl.__init__(self, parent, style=style)
404 ListCtrlAutoWidthMixin.__init__(self)
405 self.SetBackgroundColour(Colour(parent.color_background))
406 self.SetForegroundColour(Colour(parent.color_foreground))
407 self._keywords = None
408 self._plugin = plugin
409 self._create_headers()
410 self._link_attribute = self._create_link_attribute()
411 self._image_list = self._create_image_list()
412 self.Bind(wx.EVT_LEFT_UP, self.on_left_up)
414 def _create_headers(self):
415 for col, title in enumerate(_KeywordData.headers):
416 self.InsertColumn(col, title)
417 self.SetBackgroundColour(Colour(self.GetParent().color_background))
418 self.SetForegroundColour(Colour(self.GetParent().color_foreground))
419 self.SetColumnWidth(0, 250)
421 @staticmethod
422 def _create_link_attribute():
423 if wx.VERSION < (4, 1, 0): 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 attr = wx.ListItemAttr()
425 else:
426 attr = wx.ItemAttr()
427 attr.SetTextColour(wx.BLUE)
428 attr.SetFont(Font().underlined)
429 return attr
431 def _create_image_list(self):
432 imglist = wx.ImageList(16, 16)
433 imglist.Add(wx.ArtProvider.GetBitmap(wx.ART_GO_UP, wx.ART_OTHER, (16, 16)))
434 self.SetImageList(imglist, wx.IMAGE_LIST_SMALL)
435 return imglist
437 def show_keywords(self, keywords, kw_selection):
438 self._keywords = keywords
439 self.SetItemCount(len(self._keywords))
440 self.SetBackgroundColour(Colour(self.GetParent().color_secondary_background))
441 self.SetForegroundColour(Colour(self.GetParent().color_secondary_foreground))
442 if keywords:
443 index = self._keywords.index(kw_selection)
444 self.Select(index)
445 self.Focus(index)
447 def on_left_up(self, event):
448 item, flags = self.HitTest(event.Position)
449 if item == wx.NOT_FOUND:
450 return
451 kw = self._keywords[item]
452 if kw.is_user_keyword() and (flags & wx.LIST_HITTEST_ONITEMICON):
453 self._plugin.select_user_keyword_node(kw.item)
455 def OnGetItemText(self, row, col): # Overrides wx method
456 kw = self._keywords[row]
457 return [kw.name, kw.source, kw.shortdoc][col]
459 def OnGetItemImage(self, item): # Overrides wx method
460 if self._keywords[item].is_user_keyword():
461 return 0 # index in self._image_list
462 return -1 # No image