1# 2# Gramps - a GTK+/GNOME based genealogy program 3# 4# Copyright(C) 2014 Bastien Jacquet 5# 6# This program is free software; you can redistribute it and/or modify 7# it under the terms of the GNU General Public License as published by 8# the Free Software Foundation; either version 2 of the License, or 9# (at your option) any later version. 10# 11# This program is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14# GNU General Public License for more details. 15# 16# You should have received a copy of the GNU General Public License 17# along with this program; if not, write to the Free Software 18# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 19# 20from gramps.gen.const import GRAMPS_LOCALE as glocale 21 22""" 23GtkWidget showing a box for interactive-search in Gtk.TreeView 24""" 25 26#------------------------------------------------------------------------- 27# 28# Python modules 29# 30#------------------------------------------------------------------------- 31import logging 32_LOG = logging.getLogger(".widgets.interactivesearch") 33 34#------------------------------------------------------------------------- 35# 36# GTK modules 37# 38#------------------------------------------------------------------------- 39from gi.repository import Gtk, Gdk, GLib 40 41#------------------------------------------------------------------------- 42# 43# Gramps modules 44# 45#------------------------------------------------------------------------- 46from ..utils import match_primary_mask 47#------------------------------------------------------------------------- 48# 49# InteractiveSearchBox class 50# 51#------------------------------------------------------------------------- 52 53 54class InteractiveSearchBox: 55 """ 56 Mainly adapted from gtktreeview.c 57 """ 58 _SEARCH_DIALOG_TIMEOUT = 5000 59 _SEARCH_DIALOG_LAUNCH_TIMEOUT = 150 60 61 def __init__(self, treeview): 62 self._treeview = treeview 63 self._search_window = None 64 self._search_entry = None 65 self._search_entry_changed_id = 0 66 self.__disable_popdown = False 67 self._entry_flush_timeout = None 68 self._entry_launchsearch_timeout = None 69 self.__selected_search_result = 0 70 # Disable builtin interactive search by intercepting CTRL-F instead. 71 # self._treeview.connect('start-interactive-search', 72 # self.start_interactive_search) 73 74 def treeview_keypress(self, obj, event): 75 """ 76 function handling keypresses from the treeview 77 for the typeahead find capabilities 78 """ 79 if not Gdk.keyval_to_unicode(event.keyval): 80 return False 81 if self._key_cancels_search(event.keyval): 82 return False 83 self.ensure_interactive_directory() 84 85 # Make a copy of the current text 86 old_text = self._search_entry.get_text() 87 88 popup_menu_id = self._search_entry.connect("popup-menu", 89 lambda x: True) 90 91 # Move the entry off screen 92 screen = self._treeview.get_screen() 93 self._search_window.move(screen.get_width() + 1, 94 screen.get_height() + 1) 95 self._search_window.show() 96 97 # Send the event to the window. If the preedit_changed signal is 98 # emitted during this event, we will set self.__imcontext_changed 99 new_event = Gdk.Event.copy(event) 100 new_event.window = self._search_window.get_window() 101 self._search_window.realize() 102 self.__imcontext_changed = False 103 retval = self._search_window.event(new_event) 104 self._search_window.hide() 105 106 self._search_entry.disconnect(popup_menu_id) 107 108 # Intercept CTRL+F keybinding because Gtk do not allow to _replace_ it. 109 if (match_primary_mask(event.state) 110 and event.keyval in [Gdk.KEY_f, Gdk.KEY_F]): 111 self.__imcontext_changed = True 112 # self.real_start_interactive_search(event.get_device(), True) 113 114 # We check to make sure that the entry tried to handle the text, 115 # and that the text has changed. 116 new_text = self._search_entry.get_text() 117 text_modified = (old_text != new_text) 118 if (self.__imcontext_changed or # we're in a preedit 119 (retval and text_modified)): # ...or the text was modified 120 self.real_start_interactive_search(event.get_device(), False) 121 self._treeview.grab_focus() 122 return True 123 else: 124 self._search_entry.set_text("") 125 return False 126 127 def _preedit_changed(self, im_context, tree_view): 128 self.__imcontext_changed = 1 129 if(self._entry_flush_timeout): 130 GLib.source_remove(self._entry_flush_timeout) 131 self._entry_flush_timeout = GLib.timeout_add( 132 self._SEARCH_DIALOG_TIMEOUT, self.cb_entry_flush_timeout) 133 134 def ensure_interactive_directory(self): 135 toplevel = self._treeview.get_toplevel() 136 screen = self._treeview.get_screen() 137 if self._search_window: 138 if toplevel.has_group(): 139 toplevel.get_group().add_window(self._search_window) 140 elif self._search_window.has_group(): 141 self._search_window.get_group().remove_window( 142 self._search_window) 143 self._search_window.set_screen(screen) 144 return 145 146 self._search_window = Gtk.Window(type=Gtk.WindowType.POPUP) 147 self._search_window.set_screen(screen) 148 if toplevel.has_group(): 149 toplevel.get_group().add_window(self._search_window) 150 self._search_window.set_type_hint(Gdk.WindowTypeHint.UTILITY) 151 self._search_window.set_modal(True) 152 self._search_window.connect("delete-event", self._delete_event) 153 self._search_window.connect("key-press-event", self._key_press_event) 154 self._search_window.connect("button-press-event", 155 self._button_press_event) 156 self._search_window.connect("scroll-event", self._scroll_event) 157 frame = Gtk.Frame() 158 frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN) 159 frame.show() 160 self._search_window.add(frame) 161 162 vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 163 vbox.show() 164 frame.add(vbox) 165 vbox.set_border_width(3) 166 167 """ add entry """ 168 self._search_entry = Gtk.SearchEntry() 169 self._search_entry.show() 170 self._search_entry.connect("populate-popup", self._disable_popdown) 171 self._search_entry.connect("activate", self._activate) 172 self._search_entry.connect("preedit-changed", self._preedit_changed) 173 174 vbox.add(self._search_entry) 175 self._search_entry.realize() 176 177 def real_start_interactive_search(self, device, keybinding): 178 """ 179 Pops up the interactive search entry. If keybinding is TRUE then 180 the user started this by typing the start_interactive_search 181 keybinding. Otherwise, it came from just typing 182 """ 183 if (self._search_window.get_visible()): 184 return True 185 self.ensure_interactive_directory() 186 if keybinding: 187 self._search_entry.set_text("") 188 self._position_func() 189 self._search_window.show() 190 if self._search_entry_changed_id == 0: 191 self._search_entry_changed_id = \ 192 self._search_entry.connect("changed", self.delayed_changed) 193 194 # Grab focus without selecting all the text 195 self._search_entry.grab_focus() 196 self._search_entry.set_position(-1) 197 # send focus-in event 198 event = Gdk.Event() 199 event.type = Gdk.EventType.FOCUS_CHANGE 200 event.focus_change.in_ = True 201 event.focus_change.window = self._search_window.get_window() 202 self._search_entry.emit('focus-in-event', event) 203 # search first matching iter 204 self.delayed_changed(self._search_entry) 205 # uncomment when deleting delayed_changed 206 # self.search_init(self._search_entry) 207 return True 208 209 def cb_entry_flush_timeout(self): 210 event = Gdk.Event() 211 event.type = Gdk.EventType.FOCUS_CHANGE 212 event.focus_change.in_ = True 213 event.focus_change.window = self._treeview.get_window() 214 self._dialog_hide(event) 215 self._entry_flush_timeout = 0 216 return False 217 218 def delayed_changed(self, obj): 219 """ 220 This permits to start the search only a short delay after last keypress 221 This becomes useless with Gtk 3.10 Gtk.SearchEntry, which has a 222 'search-changed' signal. 223 """ 224 # renew flush timeout 225 self._renew_flush_timeout() 226 # renew search timeout 227 if self._entry_launchsearch_timeout: 228 GLib.source_remove(self._entry_launchsearch_timeout) 229 self._entry_launchsearch_timeout = GLib.timeout_add( 230 self._SEARCH_DIALOG_LAUNCH_TIMEOUT, self.search_init) 231 232 def search_init(self): 233 """ 234 This is the function performing the search 235 """ 236 self._entry_launchsearch_timeout = 0 237 text = self._search_entry.get_text() 238 if not text: 239 return 240 241 model = self._treeview.get_model() 242 if not model: 243 return 244 selection = self._treeview.get_selection() 245 # disable flush timeout while searching 246 if self._entry_flush_timeout: 247 GLib.source_remove(self._entry_flush_timeout) 248 self._entry_flush_timeout = 0 249 # search 250 # cursor_path = self._treeview.get_cursor()[0] 251 # model.get_iter(cursor_path) 252 start_iter = model.get_iter_first() 253 self.search_iter(selection, start_iter, text, 0, 1) 254 self.__selected_search_result = 1 255 # renew flush timeout 256 self._renew_flush_timeout() 257 258 def _renew_flush_timeout(self): 259 if self._entry_flush_timeout: 260 GLib.source_remove(self._entry_flush_timeout) 261 self._entry_flush_timeout = GLib.timeout_add( 262 self._SEARCH_DIALOG_TIMEOUT, self.cb_entry_flush_timeout) 263 264 def _move(self, up=False): 265 text = self._search_entry.get_text() 266 if not text: 267 return 268 269 if up and self.__selected_search_result == 1: 270 return False 271 272 model = self._treeview.get_model() 273 selection = self._treeview.get_selection() 274 # disable flush timeout while searching 275 if self._entry_flush_timeout: 276 GLib.source_remove(self._entry_flush_timeout) 277 self._entry_flush_timeout = 0 278 # search 279 start_count = self.__selected_search_result + (-1 if up else 1) 280 start_iter = model.get_iter_first() 281 found_iter = self.search_iter(selection, start_iter, text, 0, 282 start_count) 283 if found_iter: 284 self.__selected_search_result += (-1 if up else 1) 285 return True 286 else: 287 # Return to old iter 288 self.search_iter(selection, start_iter, text, 0, 289 self.__selected_search_result) 290 return False 291 # renew flush timeout 292 self._renew_flush_timeout() 293 return 294 295 def _activate(self, obj): 296 self.cb_entry_flush_timeout() 297 # If we have a row selected and it's the cursor row, we activate 298 # the row XXX 299# if self._cursor_node and \ 300# self._cursor_node.set_flag(Gtk.GTK_RBNODE_IS_SELECTED): 301# path = _gtk_tree_path_new_from_rbtree( 302# tree_view->priv->cursor_tree, 303# tree_view->priv->cursor_node) 304# gtk_tree_view_row_activated(tree_view, path, 305# tree_view->priv->focus_column) 306 307 def _button_press_event(self, obj, event): 308 if not obj: 309 return 310 # keyb_device = event.device 311 event = Gdk.Event() 312 event.type = Gdk.EventType.FOCUS_CHANGE 313 event.focus_change.in_ = True 314 event.focus_change.window = self._treeview.get_window() 315 self._dialog_hide(event) 316 317 def _disable_popdown(self, obj, menu): 318 self.__disable_popdown = 1 319 menu.connect("hide", self._enable_popdown) 320 321 def _enable_popdown(self, obj): 322 self._timeout_enable_popdown = GLib.timeout_add( 323 self._SEARCH_DIALOG_TIMEOUT, self._real_search_enable_popdown) 324 325 def _real_search_enable_popdown(self): 326 self.__disable_popdown = 0 327 328 def _delete_event(self, obj, event): 329 if not obj: 330 return 331 self._dialog_hide(None) 332 333 def _scroll_event(self, obj, event): 334 retval = False 335 if (event.direction == Gdk.ScrollDirection.UP): 336 self._move(True) 337 retval = True 338 elif (event.direction == Gdk.ScrollDirection.DOWN): 339 self._move(False) 340 retval = True 341 if retval: 342 self._renew_flush_timeout() 343 344 def _key_cancels_search(self, keyval): 345 return keyval in [Gdk.KEY_Escape, 346 Gdk.KEY_Tab, 347 Gdk.KEY_KP_Tab, 348 Gdk.KEY_ISO_Left_Tab] 349 350 def _key_press_event(self, widget, event): 351 retval = False 352 # close window and cancel the search 353 if self._key_cancels_search(event.keyval): 354 self.cb_entry_flush_timeout() 355 return True 356 # Launch search 357 if (event.keyval in [Gdk.KEY_Return, Gdk.KEY_KP_Enter]): 358 if self._entry_launchsearch_timeout: 359 GLib.source_remove(self._entry_launchsearch_timeout) 360 self._entry_launchsearch_timeout = 0 361 self.search_init() 362 retval = True 363 364 default_accel = widget.get_modifier_mask( 365 Gdk.ModifierIntent.PRIMARY_ACCELERATOR) 366 # select previous matching iter 367 if ((event.keyval in [Gdk.KEY_Up, Gdk.KEY_KP_Up]) or 368 (((event.state & (default_accel | Gdk.ModifierType.SHIFT_MASK)) 369 == (default_accel | Gdk.ModifierType.SHIFT_MASK)) 370 and (event.keyval in [Gdk.KEY_g, Gdk.KEY_G]))): 371 if(not self._move(True)): 372 widget.error_bell() 373 retval = True 374 375 # select next matching iter 376 if ((event.keyval in [Gdk.KEY_Down, Gdk.KEY_KP_Down]) or 377 (((event.state & (default_accel | Gdk.ModifierType.SHIFT_MASK)) 378 == (default_accel)) 379 and (event.keyval in [Gdk.KEY_g, Gdk.KEY_G]))): 380 if(not self._move(False)): 381 widget.error_bell() 382 retval = True 383 384 # renew the flush timeout 385 if retval: 386 self._renew_flush_timeout() 387 return retval 388 389 def _dialog_hide(self, event): 390 if self.__disable_popdown: 391 return 392 if self._search_entry_changed_id: 393 self._search_entry.disconnect(self._search_entry_changed_id) 394 self._search_entry_changed_id = 0 395 if self._entry_flush_timeout: 396 GLib.source_remove(self._entry_flush_timeout) 397 self._entry_flush_timeout = 0 398 if self._entry_launchsearch_timeout: 399 GLib.source_remove(self._entry_launchsearch_timeout) 400 self._entry_launchsearch_timeout = 0 401 if self._search_window.get_visible(): 402 # send focus-in event 403 self._search_entry.emit('focus-out-event', event) 404 self._search_window.hide() 405 self._search_entry.set_text("") 406 self._treeview.emit('focus-in-event', event) 407 self.__selected_search_result = 0 408 409 def _position_func(self, userdata=None): 410 tree_window = self._treeview.get_window() 411 screen = self._treeview.get_screen() 412 413 monitor_num = screen.get_monitor_at_window(tree_window) 414 monitor = screen.get_monitor_workarea(monitor_num) 415 416 self._search_window.realize() 417 ret, tree_x, tree_y = tree_window.get_origin() 418 tree_width = tree_window.get_width() 419 tree_height = tree_window.get_height() 420 _, requisition = self._search_window.get_preferred_size() 421 422 if tree_x + tree_width > screen.get_width(): 423 x = screen.get_width() - requisition.width 424 elif tree_x + tree_width - requisition.width < 0: 425 x = 0 426 else: 427 x = tree_x + tree_width - requisition.width 428 429 if tree_y + tree_height + requisition.height > screen.get_height(): 430 y = screen.get_height() - requisition.height 431 elif(tree_y + tree_height < 0): # isn't really possible ... 432 y = 0 433 else: 434 y = tree_y + tree_height 435 436 self._search_window.move(x, y) 437 438 def search_iter_slow(self, selection, cur_iter, text, count, n): 439 """ 440 Standard row-by-row search through all rows 441 Should work for both List/Tree models 442 Both expanded and collapsed rows are searched. 443 """ 444 model = self._treeview.get_model() 445 search_column = self._treeview.get_search_column() 446 is_tree = not (model.get_flags() & Gtk.TreeModelFlags.LIST_ONLY) 447 while True: 448 if not cur_iter: # can happen on empty list 449 return False 450 if (self.search_equal_func(model, search_column, 451 text, cur_iter)): 452 count += 1 453 if (count == n): 454 found_path = model.get_path(cur_iter) 455 self._treeview.expand_to_path(found_path) 456 self._treeview.scroll_to_cell(found_path, None, 1, 0.5, 0) 457 selection.select_path(found_path) 458 self._treeview.set_cursor(found_path) 459 return True 460 461 if is_tree and model.iter_has_child(cur_iter): 462 cur_iter = model.iter_children(cur_iter) 463 else: 464 done = False 465 while True: # search iter of next row 466 next_iter = model.iter_next(cur_iter) 467 if next_iter: 468 cur_iter = next_iter 469 done = True 470 else: 471 cur_iter = model.iter_parent(cur_iter) 472 if(not cur_iter): 473 # we've run out of tree, done with this func 474 return False 475 if done: 476 break 477 return False 478 479 @staticmethod 480 def search_equal_func(model, search_column, text, cur_iter): 481 value = model.get_value(cur_iter, search_column) 482 key1 = value.lower() 483 key2 = text.lower() 484 return key1.startswith(key2) 485 486 def search_iter(self, selection, cur_iter, text, count, n): 487 model = self._treeview.get_model() 488 is_listonly = (model.get_flags() & Gtk.TreeModelFlags.LIST_ONLY) 489 if is_listonly and hasattr(model, "node_map"): 490 return self.search_iter_sorted_column_flat(selection, cur_iter, 491 text, count, n) 492 else: 493 return self.search_iter_slow(selection, cur_iter, text, count, n) 494 495 def search_iter_sorted_column_flat(self, selection, cur_iter, text, 496 count, n): 497 """ 498 Search among the currently set search-column for a cell starting with 499 text 500 It assumes that this column is currently sorted, and as 501 a LIST_ONLY view it therefore contains index2hndl = model.node_map._index2hndl 502 which is a _sorted_ list of (sortkey, handle) tuples 503 """ 504 model = self._treeview.get_model() 505 search_column = self._treeview.get_search_column() 506 is_tree = not (model.get_flags() & Gtk.TreeModelFlags.LIST_ONLY) 507 508 # If there is a sort_key index, let's use it 509 if not is_tree and hasattr(model, "node_map"): 510 import bisect 511 index2hndl = model.node_map._index2hndl 512 513 # create lookup key from the appropriate sort_func 514 # TODO: explicitely announce the data->sortkey func in models 515 # sort_key = model.sort_func(text) 516 sort_key = glocale.sort_key(text.lower()) 517 srtkey_hndl = (sort_key, "") 518 lo_bound = 0 # model.get_path(cur_iter) 519 found_index = bisect.bisect_left(index2hndl, srtkey_hndl, lo=lo_bound) 520 # if insert position is at tail, no match 521 if found_index == len(index2hndl): 522 return False 523 srt_key, hndl = index2hndl[found_index] 524 # Check if insert position match for real 525 # (as insert position might not start with the text) 526 if not model[found_index][search_column].lower().startswith(text.lower()): 527 return False 528 found_path = Gtk.TreePath((model.node_map.real_path(found_index),)) 529 self._treeview.scroll_to_cell(found_path, None, 1, 0.5, 0) 530 selection.select_path(found_path) 531 self._treeview.set_cursor(found_path) 532 return True 533 return False 534