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

View File

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

View File

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

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)