From 7c0d90f7b40731ea5837c0ca2646f91f0e3b00e7 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 27 Jul 2016 20:07:05 +0530 Subject: [PATCH] More work on the completion popup --- src/pyj/complete.pyj | 85 +++++++++++++++++++++++++++ src/pyj/popups.pyj | 135 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 200 insertions(+), 20 deletions(-) create mode 100644 src/pyj/complete.pyj diff --git a/src/pyj/complete.pyj b/src/pyj/complete.pyj new file mode 100644 index 0000000000..be8986ef42 --- /dev/null +++ b/src/pyj/complete.pyj @@ -0,0 +1,85 @@ +# vim:fileencoding=utf-8 +# License: GPL v3 Copyright: 2016, Kovid Goyal +from __python__ import hash_literals, bound_methods + +from dom import ensure_id +from elementmaker import E +from keycodes import get_key +from popups import CompletionPopup + +class EditWithComplete: + + def __init__(self, name, placeholder=None, tooltip=None, parent=None, input_type='text', onenterkey=None): + inpt = E.input(type=input_type, name=name, title=tooltip or '', placeholder=placeholder or '') + self.input_id = ensure_id(inpt) + self.onenterkey = onenterkey + self.completion_popup = CompletionPopup(parent=parent) + self.ignore_next_input = False + inpt.addEventListener('keydown', self.onkeydown) + inpt.addEventListener('input', self.oninput) + self.completion_popup.add_associated_widget(inpt) + if parent: + parent.appendChild(inpt) + + @property + def text_input(self): + return document.getElementById(self.input_id) + + def apply_completion(self): + self.ignore_next_input = True + text = self.completion_popup.current_text + self.text_input.value = text + + def onkeydown(self, event): + k = get_key(event) + if self.completion_popup.is_visible and self.completion_popup.handle_keydown(k): + event.preventDefault(), event.stopPropagation() + return + if k is 'enter': + if self.onenterkey: + event.preventDefault(), event.stopPropagation() + self.enter_pressed() + elif k is 'tab': + if self.completion_popup.is_visible: + self.apply_completion() + event.preventDefault(), event.stopPropagation() + + def oninput(self, event): + if self.ignore_next_input: + self.ignore_next_input = False + else: + ti = self.text_input + self.completion_popup.set_query(ti.value or '') + self.completion_popup.popup(ti) + + def hide_completion_popup(self): + self.completion_popup.hide() + + def add_associated_widget(self, widget_or_id): + self.completion_popup.add_associated_widget(widget_or_id) + + def set_all_items(self, items): + self.completion_popup.set_all_items(items) + + +def create_search_bar(action, name, tooltip=None, placeholder=None, button=None): + + parent = E.div() + ewc = EditWithComplete(name, parent=parent, tooltip=tooltip, placeholder=placeholder) + + def trigger(): + ewc.hide_completion_popup() + action() + ewc.onenterkey = trigger + + if button: + ewc.add_associated_widget(button) + button.addEventListener('click', trigger) + return parent + +# }}} + +def main(container): + ewc = EditWithComplete(parent=container, placeholder='Testing edit with complete') + ewc.set_all_items('a a1 a11 a12 a13 b b1 b2 b3'.split(' ')) + ewc.text_input.focus() diff --git a/src/pyj/popups.pyj b/src/pyj/popups.pyj index 2e004b079a..7416c4d412 100644 --- a/src/pyj/popups.pyj +++ b/src/pyj/popups.pyj @@ -2,7 +2,8 @@ # License: GPL v3 Copyright: 2016, Kovid Goyal from __python__ import hash_literals, bound_methods -from dom import set_css, ensure_id +from book_list.theme import get_color +from dom import set_css, ensure_id, clear, build_rule, add_extra_css from elementmaker import E MODAL_Z_INDEX = 1000 @@ -16,26 +17,26 @@ def element_contains_click_event(element, event): r = element.getBoundingClientRect() return r.left <= event.clientX <= r.right and r.top <= event.clientY <= r.bottom -def check_for_open_popups(event): - if not shown_popups.length: - return False +def click_in_popup(event): for popup_id in shown_popups: popup = document.getElementById(popup_id) - if element_contains_click_event(popup, event): - return False + if popup and element_contains_click_event(popup, event): + return True w = associated_widgets[popup_id] if w and w.length: for wid in w: widget = document.getElementById(wid) - if element_contains_click_event(widget, event): - return False - return True + if widget and element_contains_click_event(widget, event): + return True + return False def filter_clicks(event): - if check_for_open_popups(event): + if shown_popups.length: event.stopPropagation(), event.preventDefault() - for popup in list(shown_popups): - hide_popup(popup.getAttribute('id')) + if not click_in_popup(event): + for popup_id in shown_popups: + hide_popup(popup_id) + shown_popups.clear() def install_event_filters(): window.addEventListener('click', filter_clicks, True) @@ -49,30 +50,33 @@ def create_popup(parent, idprefix): parent.appendChild(div) return pid -def show_popup(popup_id, *associated_widget_ids): +def show_popup(popup_id, associated_widget_ids=None): elem = document.getElementById(popup_id) elem.style.display = 'block' shown_popups.add(popup_id) - associated_widgets[popup_id] = set() - for aid in associated_widget_ids: - associated_widgets[popup_id].add(aid) + associated_widgets[popup_id] = associated_widget_ids def hide_popup(popup_id): elem = document.getElementById(popup_id) elem.style.display = 'none' - shown_popups.discard(popup_id) v'delete associated_widgets[popup_id]' class CompletionPopup: + CLASS = 'popup-completion-items' + CURRENT_ITEM_CLASS = 'popup-completion-current-item' + def __init__(self, parent=None, max_items=25): self.max_items = max_items self.container_id = create_popup(parent) self.items = [] + self.matches = [] c = self.container set_css(c, user_select='none') + c.appendChild(E.div(class_=self.CLASS, onclick=self._item_clicked)) self.associated_widget_ids = set() self.current_query, self.is_upwards = '', False + self.applied_query = self.current_query @property def container(self): @@ -84,21 +88,75 @@ class CompletionPopup: def set_all_items(self, items): self.items = items + self.matches = [] + self.applied_query = '' def add_associated_widget(self, widget_or_id): if type(widget_or_id) is not 'string': widget_or_id = ensure_id(widget_or_id) self.associated_widget_ids.add(widget_or_id) + def popup(self, widget): + if not self.is_visible: + if self.applied_query is not self.current_query: + self._apply_query() + if self.matches.length: + self.show_at_widget(widget) + def show_at_widget(self, w): br = w.getBoundingClientRect() if br.top > window.innerHeight - br.bottom: y, upwards = br.top, True else: y, upwards = br.bottom, False - self.show_at(br.left, y, br.width, upwards) + self._show_at(br.left, y, br.width, upwards) - def show_at(self, x, y, width, upwards): + def set_query(self, query): + self.current_query = query + if self.is_visible and self.applied_query is not self.current_query: + self._apply_query() + if not self.matches.length: + self.hide() + + def hide(self): + self.container.style.display = 'none' + + def highlight_up(self): + self.move_highlight(True) + + def highlight_down(self): + self.move_highlight(False) + + def handle_keydown(self, key): + if key is 'escape': + self.hide() + return True + if key is 'up': + self.highlight_up() + return True + if key is 'down': + self.highlight_down() + return True + return False + + @property + def current_item(self): + c = self.container + return c.querySelector('{} > div.{}'.format(self.CLASS, self.CURRENT_ITEM_CLASS)) + + def move_highlight(self, up=True): + ans = None + div = self.current_item + if div: + div.classList.remove(self.CURRENT_ITEM_CLASS) + ans = div.previousSibling if up else div.nextSibling + if not ans: + c = self.container + ans = c.lastChild if up else c.firstChild + if ans: + ans.classList.add(self.CURRENT_ITEM_CLASS) + + def _show_at(self, x, y, width, upwards): self.is_upwards = upwards c = self.container cs = c.style @@ -107,4 +165,41 @@ class CompletionPopup: cs.bottom = y + 'px' if upwards else 'auto' cs.width = width + 'px' cs.maxHeight = ((y if upwards else window.innerHeight - y) - 10) + 'px' - show_popup(self.container_id, *self.associated_widget_ids) + show_popup(self.container_id, self.associated_widget_ids) + + def _apply_query(self): + q = self.current_query.toLowerCase() + self.matches.clear() + self.applied_query = self.current_query + if not q: + self.matches = list(self.items[:self.max_items + 1]) + else: + i = 0 + while self.matches.length < self.max_items and i < self.items.length: + if self.items[i].toLowerCase().startswith(q): + self.matches.push(self.items[i]) + i += 1 + self._render_matches() + + def _render_matches(self): + c = self.container + clear(c.firstChild) + items = self.matches + if self.is_upwards: + items = reversed(items) + for m in items: + c.firstChild.appendChild(E.div(m)) + + def _item_clicked(self, ev): + self.hide() + +add_extra_css(def(): + sel = 'div.' + CompletionPopup.CLASS + style = build_rule(sel, background_color=get_color('window-background'), + border='solid 1px ' + get_color('window-foreground')) + sel += ' > div' + style += build_rule(sel, cursor='pointer', margin='1ex 1rem') + sel += ' > div.' + CompletionPopup.CURRENT_ITEM_CLASS + style += build_rule(sel, color=get_color('list-hover-foreground'), background_color=get_color('list-hover-background')) + return style +)