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

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. 

15 

16import builtins 

17import os.path 

18from functools import (cmp_to_key) 

19 

20import wx 

21from wx import Colour 

22from wx.lib.mixins.listctrl import ListCtrlAutoWidthMixin 

23 

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 

31 

32_ = wx.GetTranslation # To keep linter/code analyser happy 

33builtins.__dict__['_'] = wx.GetTranslation 

34 

35ALL_KEYWORDS = _('<all keywords>') 

36ALL_USER_KEYWORDS = _('<all user keywords>') 

37ALL_LIBRARY_KEYWORDS = _('<all library keywords>') 

38 

39SEARCH_KW = _('Search Keywords') 

40 

41 

42class KeywordSearch(Plugin): 

43 __doc__ = _("""A plugin for searching keywords based on name or documentation.""") 

44 

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 

51 

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) 

64 

65 def on_search(self, event): 

66 __ = event 

67 self._dialog.show_search_with_criteria() 

68 

69 def mark_dirty(self, message): 

70 _ = message 

71 self.dirty = True 

72 

73 def have_keywords_changed(self): 

74 if not self.dirty: 

75 return False 

76 self._update() 

77 return True 

78 

79 def _update(self): 

80 self.dirty = False 

81 self.all_keywords = self.model.get_all_keywords() 

82 

83 def search(self, pattern, search_docs, source_filter): 

84 self._criteria = _SearchCriteria(pattern, search_docs, source_filter) 

85 return self._search() 

86 

87 def _search(self): 

88 return [kw for kw in self.all_keywords if self._criteria.matches(kw)] 

89 

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 [] 

96 

97 def _show_resource(self, resource): 

98 self._dialog.show_search_with_criteria(source=resource) 

99 

100 def show_search_for(self, pattern): 

101 self._dialog.show_search_with_criteria(pattern=pattern) 

102 

103 def disable(self): 

104 self.unregister_actions() 

105 self.unsubscribe_all() 

106 

107 

108class _SearchCriteria(object): 

109 

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

114 

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

121 

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

130 

131 @staticmethod 

132 def _contains(string, pattern): 

133 return utils.normalize(pattern) in utils.normalize(string) 1klhinfg

134 

135 

136class KeywordSearchDialog(RIDEDialog): 

137 

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() 

151 

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)) 

158 

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) 

167 

168 @staticmethod 

169 def _horizontal_sizer(): 

170 return wx.BoxSizer(wx.HORIZONTAL) 

171 

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) 

178 

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) 

183 

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) 

191 

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) 

198 

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) 

203 

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) 

213 

214 def _add_to_sizer(self, component): 

215 self.Sizer.Add(component, 1, wx.EXPAND | wx.ALL, 3) 

216 

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() 

221 

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) 

232 

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() 

239 

240 def on_activate(self, event): 

241 __ = event 

242 if self._plugin.have_keywords_changed(): 

243 self._update_sources() 

244 self._populate_search() 

245 

246 def on_use_doc_change(self, event): 

247 __ = event 

248 self._populate_search() 

249 

250 def on_search(self, event): 

251 __ = event 

252 self._sort_order.searched(self._get_search_text()) 

253 self._populate_search() 

254 

255 def on_source_filter_change(self, event): 

256 self.on_use_doc_change(event) 

257 

258 def on_key(self, event): 

259 # Needed for HtmlWindow callback 

260 pass 

261 

262 def on_item_selected(self, event): 

263 self._last_selected_kw = self._keywords[event.Index] 

264 self._update_details() 

265 

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) 

274 

275 def on_close(self, event): 

276 __ = event 

277 self.Hide() 

278 

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() 

286 

287 def _get_search_criteria(self): 

288 return self._get_search_text(), self._use_doc.GetValue(), self._source_filter.GetValue() 

289 

290 def _get_search_text(self): 

291 return self._search_control.GetValue().lower() 

292 

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() 

299 

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() 

307 

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() 

313 

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) 

318 

319 def _show(self): 

320 if not self.IsShown(): 

321 self.Show() 

322 self.Raise() 

323 

324 

325class _SortOrder(object): 

326 

327 def __init__(self): 

328 self.sort_up = True 1ajdbec

329 self.column = 0 1ajdbec

330 self.default_order = False 1ajdbec

331 

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

336 

337 def swap_direction(self): 

338 self.sort_up = not self.sort_up 1dc

339 

340 @staticmethod 

341 def is_sortable_column(col): 

342 return col < 2 

343 

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

351 

352 def _has_been_sorted_by(self, col): 

353 return self.column == col and not self.default_order 1dec

354 

355 

356class _KeywordData(list): 

357 headers = [_('Name'), _('Source'), _('Description')] 

358 headers_attr = ['Name', 'Source', 'Description'] # Non-translated names 

359 

360 def __init__(self, keywords, sort_order, search_criteria=None): 

361 self.extend(self._sort(keywords, sort_order, search_criteria)) 1jdbec

362 

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

367 

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

380 

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) 

385 

386 @staticmethod 

387 def m_cmp(a, b): 

388 return (a > b) - (a < b) 1jdbec

389 

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)) 

393 

394 @staticmethod 

395 def _value_lowerer(kw, attr_name): 

396 return getattr(kw, attr_name).lower() 1jdbec

397 

398 

399class _KeywordList(wx.ListCtrl, ListCtrlAutoWidthMixin): 

400 

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) 

413 

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) 

420 

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 

430 

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 

436 

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) 

446 

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) 

454 

455 def OnGetItemText(self, row, col): # Overrides wx method 

456 kw = self._keywords[row] 

457 return [kw.name, kw.source, kw.shortdoc][col] 

458 

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