2

I am writing an application in python with urwid. I need an Edit widget with autocompletion. Haven't seen one in the documentation so I have tried implementing it on my own based on the pop_up example.

However, I am faced with the fact that the pop_up widget has the focus which is a problem because:

  • The cursor in the edit widget is not visible. When using the left and right arrow keys without counting how often you have pressed them you do not know where the next character will be inserted.
  • All user input goes to the pop_up widget and not to the PopUpLauncher, although most of the key events are meant for the edit widget. I cannot call Edit.keypress because I don't know the size of the edit widget. Therefore I need to duplicate code from urwid.

How can I set the PopUpLauncher to have the focus?

From there on this answer might be helpful.


A strongly simplified version of my custom widget class:

#!/usr/bin/env python

import urwid


class AutoCompleteEdit(urwid.PopUpLauncher):

    CMD_INSERT_SELECTED = "complete"

    command_map = urwid.CommandMap()
    command_map['tab'] = CMD_INSERT_SELECTED

    def __init__(self, get_completions):
        self.edit_widget = urwid.Edit()
        self.__super.__init__(self.edit_widget)
        self.get_completions = get_completions

    # ------- user input ------

    def keypress(self, size, key):
        cmd = self.command_map[key]
        if cmd is None:
            out = self.__super.keypress(size, key)
            self.update_completions()
            return out

        return self.__super.keypress(size, key)

    def forwarded_keypress(self, key):
        if self.edit_widget.valid_char(key):
            #if (isinstance(key, text_type) and not isinstance(self._caption, text_type)):
            #   # screen is sending us unicode input, must be using utf-8
            #   # encoding because that's all we support, so convert it
            #   # to bytes to match our caption's type
            #   key = key.encode('utf-8')
            self.edit_widget.insert_text(key)
            self.update_completions()
            return

        cmd = self.command_map[key]
        if cmd == self.CMD_INSERT_SELECTED:
            self.insert_selected()
            return

        elif cmd == urwid.CURSOR_LEFT:
            p = self.edit_widget.edit_pos
            if p == 0:
                return key
            p = urwid.move_prev_char(self.edit_widget.edit_text, 0, p)
            self.edit_widget.set_edit_pos(p)
        elif cmd == urwid.CURSOR_RIGHT:
            p = self.edit_widget.edit_pos
            if p >= len(self.edit_widget.edit_text):
                return key
            p = urwid.move_next_char(self.edit_widget.edit_text, p, len(self.edit_widget.edit_text))
            self.edit_widget.set_edit_pos(p)
        elif key == "backspace":
            self.edit_widget.pref_col_maxcol = None, None
            if not self.edit_widget._delete_highlighted():
                p = self.edit_widget.edit_pos
                if p == 0:
                    return key
                p = urwid.move_prev_char(self.edit_widget.edit_text,0,p)
                self.edit_widget.set_edit_text(self.edit_widget.edit_text[:p] + self.edit_widget.edit_text[self.edit_widget.edit_pos:])
                self.edit_widget.set_edit_pos(p)
        elif key == "delete":
            self.edit_widget.pref_col_maxcol = None, None
            if not self.edit_widget._delete_highlighted():
                p = self.edit_widget.edit_pos
                if p >= len(self.edit_widget.edit_text):
                    return key
                p = urwid.move_next_char(self.edit_widget.edit_text,p,len(self.edit_widget.edit_text))
                self.edit_widget.set_edit_text(self.edit_widget.edit_text[:self.edit_widget.edit_pos] + self.edit_widget.edit_text[p:])
        else:
            return key

        self.update_completions()
        return key

    def update_completions(self):
        i = self.edit_widget.edit_pos
        text = self.edit_widget.edit_text[:i]
        prefix, completions = self.get_completions(text)
        self.prefix = prefix
        self.completions = completions

        if not self.completions:
            if self.is_open():
                self.close_pop_up()
            return

        if not self.is_open():
            self.open_pop_up()

        self._pop_up_widget.update_completions(completions)

    def insert_selected(self):
        text = self._pop_up_widget.get_selected()

        i = self.edit_widget.edit_pos - len(self.prefix)
        assert i >= 0
        text = text[i:]
        self.edit_widget.insert_text(text)

        self.close_pop_up()

    # ------- internal ------

    def is_open(self):
        return self._pop_up_widget

    # ------- implementation of abstract methods ------

    def create_pop_up(self):
        return PopUpList(self.forwarded_keypress)

    def get_pop_up_parameters(self):
        height = len(self.completions)
        width = max(len(x) for x in self.completions)
        return {'left':len(self.prefix), 'top':1, 'overlay_width':width, 'overlay_height':height}


class PopUpList(urwid.WidgetWrap):

    ATTR = 'popup-button'
    ATTR_FOCUS = 'popup-button-focus'

    def __init__(self, keypress_callback):
        self.body = urwid.SimpleListWalker([urwid.Text("")])
        widget = urwid.ListBox(self.body)
        widget = urwid.AttrMap(widget, self.ATTR)
        self.__super.__init__(widget)
        self.keypress_callback = keypress_callback

    def update_completions(self, completions):
        self.body.clear()
        for x in completions:
            widget = ListEntry(x)
            widget = urwid.AttrMap(widget, self.ATTR, self.ATTR_FOCUS)
            self.body.append(widget)

    def get_selected(self):
        focus_widget, focus_pos = self.body.get_focus()
        return self.body[focus_pos].original_widget.text

    def keypress(self, size, key):
        key = self.keypress_callback(key)
        if key:
            return super().keypress(size, key)


class ListEntry(urwid.Text):

    #https://stackoverflow.com/a/56759094

    _selectable = True

    signals = ["click"]

    def keypress(self, size, key):
        """
        Send 'click' signal on 'activate' command.
        """
        if self._command_map[key] != urwid.ACTIVATE:
            return key

        self._emit('click')

    def mouse_event(self, size, event, button, x, y, focus):
        """
        Send 'click' signal on button 1 press.
        """
        if button != 1 or not urwid.util.is_mouse_press(event):
            return False

        self._emit('click')
        return True


if __name__ == '__main__':
    palette = [
        ('popup-button', 'white', 'dark blue'),
        ('popup-button-focus', 'white,standout', 'dark blue'),
        ('error', 'dark red', 'default'),
    ]

    completions = ["hello", "hi", "world", "earth", "universe"]

    def get_completions(start):
        i = start.rfind(" ")
        if i == -1:
            prefix = ""
        else:
            i += 1
            prefix = start[:i]
            start  = start[i:]

        return prefix, [word for word in completions if word.startswith(start)]

    widget = AutoCompleteEdit(get_completions)
    widget = urwid.Filler(widget)

    #WARNING: note the pop_ups=True
    urwid.MainLoop(widget, palette, pop_ups=True).run()
jakun
  • 624
  • 5
  • 13
  • Hey @jakun, were you successful in adding autocomplete feature to Urwid? I'm choosing which lib to go with. Currently leaning towards [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/) for the lack of autocomplete in Urwid (a must-have feature for my project)... – Renato Byrro Oct 23 '20 at 20:26
  • Found [this project](https://github.com/rr-/urwid_readline/blob/e02bf1312ec830fc4f0f573dc634780ec9f22961/example/example.py#L24), by the way, which seems to add autocomplete to Urwid. But it doesn't seem as flexible as the prompt_toolkit one. – Renato Byrro Oct 23 '20 at 20:32
  • @RenatoByrro no, unfortunately not. I did not have the time to continue on that project. Thanks for sharing the links. – jakun Oct 25 '20 at 14:54

1 Answers1

0

I have found a solution for the first part of the problem at least (visibility of the cursor): overriding the render method in the AutoCompleteEdit class:

    def render(self, size, focus=False):
        if self.is_open():
            focus = True
        return self.__super.render(size, focus)

The second problem, code duplication, still remains. Actually it's more than just code duplication because some functionality (moving the cursor to the very beginning or the very end) is using the size argument of the keypress method. I don't know the size so I can't just copy it out of there. So if someone knows a better way I would be grateful.

jakun
  • 624
  • 5
  • 13