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