From d426f6232e2069de9f051ef5b0f45513bfa55b9c Mon Sep 17 00:00:00 2001 From: Bastien Jacquet Date: Sat, 27 Dec 2014 03:10:59 +0100 Subject: [PATCH] Our own interactive-search enabling customized and delayed search. This commit provides same search capabilities as Gtk's. The only difference should be the search being delayed by 150ms after last keypress. Signed-off-by: Bastien Jacquet --- gramps/gui/selectors/baseselector.py | 5 + gramps/gui/views/listview.py | 27 +- gramps/gui/views/treemodels/flatbasemodel.py | 1 + gramps/gui/widgets/interactivesearchbox.py | 479 +++++++++++++++++++ 4 files changed, 502 insertions(+), 10 deletions(-) create mode 100644 gramps/gui/widgets/interactivesearchbox.py diff --git a/gramps/gui/selectors/baseselector.py b/gramps/gui/selectors/baseselector.py index 4e2d09a57..7646f32f3 100644 --- a/gramps/gui/selectors/baseselector.py +++ b/gramps/gui/selectors/baseselector.py @@ -35,6 +35,7 @@ from gi.repository import Pango from ..managedwindow import ManagedWindow from ..filters import SearchBar from ..glade import Glade +from ..widgets.interactivesearchbox import InteractiveSearchBox #------------------------------------------------------------------------- # @@ -86,6 +87,10 @@ class BaseSelector(ManagedWindow): self.tree.connect('row-activated', self._on_row_activated) self.tree.grab_focus() + # connect to signal for custom interactive-search + self.searchbox = InteractiveSearchBox(self.tree) + self.tree.connect('key-press-event', self.searchbox.treeview_keypress) + #add the search bar self.search_bar = SearchBar(dbstate, uistate, self.build_tree) filter_box = self.search_bar.build() diff --git a/gramps/gui/views/listview.py b/gramps/gui/views/listview.py index 3f7330781..7bac93049 100644 --- a/gramps/gui/views/listview.py +++ b/gramps/gui/views/listview.py @@ -71,6 +71,7 @@ _ = glocale.translation.sgettext from ..ddtargets import DdTargets from ..plug.quick import create_quickreport_menu, create_web_connect_menu from ..utils import is_right_click +from ..widgets.interactivesearchbox import InteractiveSearchBox #---------------------------------------------------------------- # @@ -157,6 +158,7 @@ class ListView(NavigationView): self.list.set_fixed_height_mode(True) self.list.connect('button-press-event', self._button_press) self.list.connect('key-press-event', self._key_press) + self.searchbox = InteractiveSearchBox(self.list) if self.drag_info(): self.list.connect('drag_data_get', self.drag_data_get) @@ -888,6 +890,9 @@ class ListView(NavigationView): if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): self.edit(obj) return True + # Custom interactive search + if event.string: + return self.searchbox.treeview_keypress(obj, event) return False def _key_press_tree(self, obj, event): @@ -896,16 +901,15 @@ class ListView(NavigationView): ENTER --> edit selection or open group node SHIFT+ENTER --> open group node and all children nodes """ - if event.get_state() & Gdk.ModifierType.SHIFT_MASK: - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - store, paths = self.selection.get_selected_rows() - if paths: - iter_ = self.model.get_iter(paths[0]) - handle = self.model.get_handle_from_iter(iter_) - if len(paths) == 1 and handle is None: - return self.expand_collapse_tree_branch() - else: - if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): + if (event.get_state() & Gdk.ModifierType.SHIFT_MASK and + event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter)): + store, paths = self.selection.get_selected_rows() + if paths: + iter_ = self.model.get_iter(paths[0]) + handle = self.model.get_handle_from_iter(iter_) + if len(paths) == 1 and handle is None: + return self.expand_collapse_tree_branch() + elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): store, paths = self.selection.get_selected_rows() if paths: iter_ = self.model.get_iter(paths[0]) @@ -915,6 +919,9 @@ class ListView(NavigationView): else: self.edit(obj) return True + elif event.string: + # Custom interactive search + return self.searchbox.treeview_keypress(obj, event) return False def expand_collapse_tree(self): diff --git a/gramps/gui/views/treemodels/flatbasemodel.py b/gramps/gui/views/treemodels/flatbasemodel.py index ebc1425e5..c2bfe6d10 100644 --- a/gramps/gui/views/treemodels/flatbasemodel.py +++ b/gramps/gui/views/treemodels/flatbasemodel.py @@ -475,6 +475,7 @@ class FlatBaseModel(GObject.GObject, Gtk.TreeModel): col = self.sort_map[scol][1] else: col = scol + # get the function that maps data to sort_keys self.sort_func = lambda x: glocale.sort_key(self.smap[col](x)) self.sort_col = scol self.skip = skip diff --git a/gramps/gui/widgets/interactivesearchbox.py b/gramps/gui/widgets/interactivesearchbox.py new file mode 100644 index 000000000..34da0978f --- /dev/null +++ b/gramps/gui/widgets/interactivesearchbox.py @@ -0,0 +1,479 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright(C) 2014 Bastien Jacquet +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +# +from gi.overrides.Gtk import TreeView, Gtk +from gramps.gen.const import GRAMPS_LOCALE as glocale + +""" +GtkWidget showing a box for interactive-search in Gtk.TreeView +""" + +#------------------------------------------------------------------------- +# +# Python modules +# +#------------------------------------------------------------------------- +import logging +_LOG = logging.getLogger(".widgets.interactivesearch") + +#------------------------------------------------------------------------- +# +# GTK modules +# +#------------------------------------------------------------------------- +from gi.repository import GObject, Gtk, Gdk + +#------------------------------------------------------------------------- +# +# InteractiveSearchBox class +# +#------------------------------------------------------------------------- + + +class InteractiveSearchBox(): + """ + Mainly adapted from gtktreeview.c + """ + _SEARCH_DIALOG_TIMEOUT = 5000 + _SEARCH_DIALOG_LAUNCH_TIMEOUT = 150 + + def __init__(self, treeview): + self._treeview = treeview + self._search_window = None + self._search_entry = None + self._search_entry_changed_id = 0 + self.__disable_popdown = False + self._entry_flush_timeout = None + self._entry_launchsearch_timeout = None + self.__selected_search_result = None + # Disable builtin interactive search by intercepting CTRL-F instead. + # self._treeview.connect('start-interactive-search', + # self.start_interactive_search) + + def treeview_keypress(self, obj, event): + """ + function handling keypresses from the treeview + for the typeahead find capabilities + """ + if not event.string: + return False + if self._key_cancels_search(event.keyval): + return False + self.ensure_interactive_directory() + + # Make a copy of the current text + old_text = self._search_entry.get_text() + + popup_menu_id = self._search_entry.connect("popup-menu", + lambda x: True) + + # Move the entry off screen + screen = self._treeview.get_screen() + self._search_window.move(screen.get_width() + 1, + screen.get_height() + 1) + self._search_window.show() + + # Send the event to the window. If the preedit_changed signal is + # emitted during this event, we will set self.__imcontext_changed + new_event = Gdk.Event.copy(event) + new_event.window = self._search_window.get_window() + self._search_window.realize() + self.__imcontext_changed = False + retval = self._search_window.event(new_event) + self._search_window.hide() + + self._search_entry.disconnect(popup_menu_id) + + # Intercept CTRL+F keybinding because Gtk do not allow to _replace_ it. + default_accel = obj.get_modifier_mask( + Gdk.ModifierIntent.PRIMARY_ACCELERATOR) + if ((event.state & (default_accel | Gdk.ModifierType.CONTROL_MASK)) + == (default_accel | Gdk.ModifierType.CONTROL_MASK) + and event.keyval in [Gdk.KEY_f, Gdk.KEY_F]): + self.__imcontext_changed = True + # self.real_start_interactive_search(event.get_device(), True) + + # We check to make sure that the entry tried to handle the text, + # and that the text has changed. + new_text = self._search_entry.get_text() + text_modified = (old_text != new_text) + if (self.__imcontext_changed or # we're in a preedit + (retval and text_modified)): # ...or the text was modified + self.real_start_interactive_search(event.get_device(), False) + self._treeview.grab_focus() + return True + else: + self._search_entry.set_text("") + return False + + def _preedit_changed(self, im_context, tree_view): + self.__imcontext_changed = 1 + if(self._entry_flush_timeout): + GObject.source_remove(self._entry_flush_timeout) + self._entry_flush_timeout = GObject.timeout_add( + self._SEARCH_DIALOG_TIMEOUT, self.cb_entry_flush_timeout) + + def ensure_interactive_directory(self): + toplevel = self._treeview.get_toplevel() + screen = self._treeview.get_screen() + if self._search_window: + if toplevel.has_group(): + toplevel.get_group().add_window(self._search_window) + elif self._search_window.has_group(): + self._search_window.get_group().remove_window( + self._search_window) + self._search_window.set_screen(screen) + return + + self._search_window = Gtk.Window(Gtk.WindowType.POPUP) + self._search_window.set_screen(screen) + if toplevel.has_group(): + toplevel.get_group().add_window(self._search_window) + self._search_window.set_type_hint(Gdk.WindowTypeHint.UTILITY) + self._search_window.set_modal(True) + self._search_window.connect("delete-event", self._delete_event) + self._search_window.connect("key-press-event", self._key_press_event) + self._search_window.connect("button-press-event", + self._button_press_event) + self._search_window.connect("scroll-event", self._scroll_event) + frame = Gtk.Frame() + frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + frame.show() + self._search_window.add(frame) + + vbox = Gtk.VBox() + vbox.show() + frame.add(vbox) + vbox.set_border_width(3) + + """ add entry """ + # To be change by Gtk 3.10 SearchEntry when agreed + if (Gtk.get_major_version(), Gtk.get_minor_version()) >= (3, 6): + self._search_entry = Gtk.SearchEntry() + else: + self._search_entry = Gtk.Entry() + self._search_entry.show() + self._search_entry.connect("populate-popup", self._disable_popdown) + self._search_entry.connect("activate", self._activate) + self._search_entry.connect("preedit-changed", self._preedit_changed) + + vbox.add(self._search_entry) + self._search_entry.realize() + + def real_start_interactive_search(self, device, keybinding): + """ + Pops up the interactive search entry. If keybinding is TRUE then + the user started this by typing the start_interactive_search + keybinding. Otherwise, it came from just typing + """ + if (self._search_window.get_visible()): + return True + self.ensure_interactive_directory() + if keybinding: + self._search_entry.set_text("") + self._position_func() + self._search_window.show() + if self._search_entry_changed_id == 0: + self._search_entry_changed_id = \ + self._search_entry.connect("changed", self.delayed_changed) + + # Grab focus without selecting all the text + self._search_entry.grab_focus() + self._search_entry.set_position(-1) + # send focus-in event + event = Gdk.Event(Gdk.EventType.FOCUS_CHANGE) + event.focus_change.in_ = True + event.focus_change.window = self._search_window.get_window() + self._search_entry.emit('focus-in-event', event) + # search first matching iter + self.delayed_changed(self._search_entry) + # uncomment when deleting delayed_changed + # self.search_init(self._search_entry) + return True + + def cb_entry_flush_timeout(self): + event = Gdk.Event(Gdk.EventType.FOCUS_CHANGE) + event.focus_change.in_ = True + event.focus_change.window = self._treeview.get_window() + self._dialog_hide(event) + self._entry_flush_timeout = 0 + return False + + def delayed_changed(self, obj): + """ + This permits to start the search only a short delay after last keypress + This becomes useless with Gtk 3.10 Gtk.SearchEntry, which has a + 'search-changed' signal. + """ + # renew flush timeout + self._renew_flush_timeout() + # renew search timeout + if self._entry_launchsearch_timeout: + GObject.source_remove(self._entry_launchsearch_timeout) + self._entry_launchsearch_timeout = GObject.timeout_add( + self._SEARCH_DIALOG_LAUNCH_TIMEOUT, self.search_init) + + def search_init(self): + """ + This is the function performing the search + """ + self._entry_launchsearch_timeout = 0 + text = self._search_entry.get_text() + if not text: + return + + model = self._treeview.get_model() + selection = self._treeview.get_selection() + # disable flush timeout while searching + if self._entry_flush_timeout: + GObject.source_remove(self._entry_flush_timeout) + self._entry_flush_timeout = 0 + # search + # cursor_path = self._treeview.get_cursor()[0] + # model.get_iter(cursor_path) + start_iter = model.get_iter_first() + self.search_iter(selection, start_iter, text, 0, 1) + self.__selected_search_result = 1 + # renew flush timeout + self._renew_flush_timeout() + + def _renew_flush_timeout(self): + if self._entry_flush_timeout: + GObject.source_remove(self._entry_flush_timeout) + self._entry_flush_timeout = GObject.timeout_add( + self._SEARCH_DIALOG_TIMEOUT, self.cb_entry_flush_timeout) + + def _move(self, up=False): + text = self._search_entry.get_text() + if not text: + return + + if up and self.__selected_search_result == 1: + return False + + model = self._treeview.get_model() + selection = self._treeview.get_selection() + # disable flush timeout while searching + if self._entry_flush_timeout: + GObject.source_remove(self._entry_flush_timeout) + self._entry_flush_timeout = 0 + # search + start_count = self.__selected_search_result + (-1 if up else 1) + start_iter = model.get_iter_first() + found_iter = self.search_iter(selection, start_iter, text, 0, + start_count) + if found_iter: + self.__selected_search_result += (-1 if up else 1) + return True + else: + # Return to old iter + self.search_iter(selection, start_iter, text, 0, + self.__selected_search_result) + return False + # renew flush timeout + self._renew_flush_timeout() + return + + def _activate(self, obj): + self.cb_entry_flush_timeout() + # If we have a row selected and it's the cursor row, we activate + # the row XXX +# if self._cursor_node and \ +# self._cursor_node.set_flag(Gtk.GTK_RBNODE_IS_SELECTED): +# path = _gtk_tree_path_new_from_rbtree( +# tree_view->priv->cursor_tree, +# tree_view->priv->cursor_node) +# gtk_tree_view_row_activated(tree_view, path, +# tree_view->priv->focus_column) + + def _button_press_event(self, obj, event): + if not obj: + return + # keyb_device = event.device + event = Gdk.Event(Gdk.EventType.FOCUS_CHANGE) + event.focus_change.in_ = True + event.focus_change.window = self._treeview.get_window() + self._dialog_hide(event) + + def _disable_popdown(self, obj, menu): + self.__disable_popdown = 1 + menu.connect("hide", self._enable_popdown) + + def _enable_popdown(self, obj): + self._timeout_enable_popdown = GObject.timeout_add( + self._SEARCH_DIALOG_TIMEOUT, self._real_search_enable_popdown) + + def _real_search_enable_popdown(self): + self.__disable_popdown = 0 + + def _delete_event(self, obj, event): + if not obj: + return + self._dialog_hide(None) + + def _scroll_event(self, obj, event): + retval = False + if (event.direction == Gdk.ScrollDirection.UP): + self._move(True) + retval = True + elif (event.direction == Gdk.ScrollDirection.DOWN): + self._move(False) + retval = True + if retval: + self._renew_flush_timeout() + + def _key_cancels_search(self, keyval): + return keyval in [Gdk.KEY_Escape, + Gdk.KEY_Tab, + Gdk.KEY_KP_Tab, + Gdk.KEY_ISO_Left_Tab] + + def _key_press_event(self, widget, event): + retval = False + # close window and cancel the search + if self._key_cancels_search(event.keyval): + self.cb_entry_flush_timeout() + return True + # Launch search + if (event.keyval in [Gdk.KEY_Return, Gdk.KEY_KP_Enter]): + if self._entry_launchsearch_timeout: + GObject.source_remove(self._entry_launchsearch_timeout) + self._entry_launchsearch_timeout = 0 + self.search_init() + retval = True + + default_accel = widget.get_modifier_mask( + Gdk.ModifierIntent.PRIMARY_ACCELERATOR) + # select previous matching iter + if ((event.keyval in [Gdk.KEY_Up, Gdk.KEY_KP_Up]) or + (((event.state & (default_accel | Gdk.ModifierType.SHIFT_MASK)) + == (default_accel | Gdk.ModifierType.SHIFT_MASK)) + and (event.keyval in [Gdk.KEY_g, Gdk.KEY_G]))): + if(not self._move(True)): + widget.error_bell() + retval = True + + # select next matching iter + if ((event.keyval in [Gdk.KEY_Down, Gdk.KEY_KP_Down]) or + (((event.state & (default_accel | Gdk.ModifierType.SHIFT_MASK)) + == (default_accel)) + and (event.keyval in [Gdk.KEY_g, Gdk.KEY_G]))): + if(not self._move(False)): + widget.error_bell() + retval = True + + # renew the flush timeout + if retval: + self._renew_flush_timeout() + return retval + + def _dialog_hide(self, event): + if self.__disable_popdown: + return + if self._search_entry_changed_id: + self._search_entry.disconnect(self._search_entry_changed_id) + self._search_entry_changed_id = 0 + if self._entry_flush_timeout: + GObject.source_remove(self._entry_flush_timeout) + self._entry_flush_timeout = 0 + if self._entry_launchsearch_timeout: + GObject.source_remove(self._entry_launchsearch_timeout) + self._entry_launchsearch_timeout = 0 + if self._search_window.get_visible(): + # send focus-in event + self._search_entry.emit('focus-out-event', event) + self._search_window.hide() + self._search_entry.set_text("") + self._treeview.emit('focus-in-event', event) + self.__selected_search_result = None + + def _position_func(self, userdata=None): + tree_window = self._treeview.get_window() + screen = self._treeview.get_screen() + + monitor_num = screen.get_monitor_at_window(tree_window) + monitor = screen.get_monitor_workarea(monitor_num) + + self._search_window.realize() + ret, tree_x, tree_y = tree_window.get_origin() + tree_width = tree_window.get_width() + tree_height = tree_window.get_height() + _, requisition = self._search_window.get_preferred_size() + + if tree_x + tree_width > screen.get_width(): + x = screen.get_width() - requisition.width + elif tree_x + tree_width - requisition.width < 0: + x = 0 + else: + x = tree_x + tree_width - requisition.width + + if tree_y + tree_height + requisition.height > screen.get_height(): + y = screen.get_height() - requisition.height + elif(tree_y + tree_height < 0): # isn't really possible ... + y = 0 + else: + y = tree_y + tree_height + + self._search_window.move(x, y) + + def search_iter(self, selection, cur_iter, text, count, n): + """ + Standard row-by-row search through all rows + Should work for both List/Tree models + Both expanded and collapsed rows are searched. + """ + model = self._treeview.get_model() + search_column = self._treeview.get_search_column() + is_tree = not (model.get_flags() & Gtk.TreeModelFlags.LIST_ONLY) + while True: + if (self.search_equal_func(model, search_column, + text, cur_iter)): + count += 1 + if (count == n): + found_path = model.get_path(cur_iter) + self._treeview.expand_to_path(found_path) + self._treeview.scroll_to_cell(found_path, None, 1, 0.5, 0) + selection.select_path(found_path) + self._treeview.set_cursor(found_path) + return True + + if is_tree and model.iter_has_child(cur_iter): + cur_iter = model.iter_children(cur_iter) + else: + done = False + while True: # search iter of next row + next_iter = model.iter_next(cur_iter) + if next_iter: + cur_iter = next_iter + done = True + else: + cur_iter = model.iter_parent(cur_iter) + if(not cur_iter): + # we've run out of tree, done with this func + return False + if done: + break + return False + + @staticmethod + def search_equal_func(model, search_column, text, cur_iter): + value = model.get_value(cur_iter, search_column) + key1 = value.lower() + key2 = text.lower() + return key1.startswith(key2) \ No newline at end of file