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 <bastien.jacquet_dev@m4x.org>
This commit is contained in:
Bastien Jacquet 2014-12-27 03:10:59 +01:00
parent 22ef07cdeb
commit d426f6232e
4 changed files with 502 additions and 10 deletions

View File

@ -35,6 +35,7 @@ from gi.repository import Pango
from ..managedwindow import ManagedWindow from ..managedwindow import ManagedWindow
from ..filters import SearchBar from ..filters import SearchBar
from ..glade import Glade 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.connect('row-activated', self._on_row_activated)
self.tree.grab_focus() 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 #add the search bar
self.search_bar = SearchBar(dbstate, uistate, self.build_tree) self.search_bar = SearchBar(dbstate, uistate, self.build_tree)
filter_box = self.search_bar.build() filter_box = self.search_bar.build()

View File

@ -71,6 +71,7 @@ _ = glocale.translation.sgettext
from ..ddtargets import DdTargets from ..ddtargets import DdTargets
from ..plug.quick import create_quickreport_menu, create_web_connect_menu from ..plug.quick import create_quickreport_menu, create_web_connect_menu
from ..utils import is_right_click 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.set_fixed_height_mode(True)
self.list.connect('button-press-event', self._button_press) self.list.connect('button-press-event', self._button_press)
self.list.connect('key-press-event', self._key_press) self.list.connect('key-press-event', self._key_press)
self.searchbox = InteractiveSearchBox(self.list)
if self.drag_info(): if self.drag_info():
self.list.connect('drag_data_get', self.drag_data_get) 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): if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self.edit(obj) self.edit(obj)
return True return True
# Custom interactive search
if event.string:
return self.searchbox.treeview_keypress(obj, event)
return False return False
def _key_press_tree(self, obj, event): def _key_press_tree(self, obj, event):
@ -896,16 +901,15 @@ class ListView(NavigationView):
ENTER --> edit selection or open group node ENTER --> edit selection or open group node
SHIFT+ENTER --> open group node and all children nodes SHIFT+ENTER --> open group node and all children nodes
""" """
if event.get_state() & Gdk.ModifierType.SHIFT_MASK: if (event.get_state() & Gdk.ModifierType.SHIFT_MASK and
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter)):
store, paths = self.selection.get_selected_rows() store, paths = self.selection.get_selected_rows()
if paths: if paths:
iter_ = self.model.get_iter(paths[0]) iter_ = self.model.get_iter(paths[0])
handle = self.model.get_handle_from_iter(iter_) handle = self.model.get_handle_from_iter(iter_)
if len(paths) == 1 and handle is None: if len(paths) == 1 and handle is None:
return self.expand_collapse_tree_branch() return self.expand_collapse_tree_branch()
else: elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
store, paths = self.selection.get_selected_rows() store, paths = self.selection.get_selected_rows()
if paths: if paths:
iter_ = self.model.get_iter(paths[0]) iter_ = self.model.get_iter(paths[0])
@ -915,6 +919,9 @@ class ListView(NavigationView):
else: else:
self.edit(obj) self.edit(obj)
return True return True
elif event.string:
# Custom interactive search
return self.searchbox.treeview_keypress(obj, event)
return False return False
def expand_collapse_tree(self): def expand_collapse_tree(self):

View File

@ -475,6 +475,7 @@ class FlatBaseModel(GObject.GObject, Gtk.TreeModel):
col = self.sort_map[scol][1] col = self.sort_map[scol][1]
else: else:
col = scol 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_func = lambda x: glocale.sort_key(self.smap[col](x))
self.sort_col = scol self.sort_col = scol
self.skip = skip self.skip = skip

View File

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