Auto Completion for TextCtrl not suggesting everything from result array

I ended up doing my own little implementation of what I needed and called it SuggestionsTextCtrl. It’s definitely not perfect and serves what I need, but (as the HR people would say): it has potential to grow :wink:

For anyone interested here is a ready-to-use snippet, feel free to use and contribute:

import wx



class SuggestionsPopup(wx.PopupTransientWindow):
    def __init__(self, parent, style, result_callback=None):
        wx.PopupTransientWindow.__init__(self, parent, style)

        self.result_callback = result_callback

        self.panel = wx.Panel(self)

        self.list_ctrl = wx.ListCtrl(self.panel, style=wx.LC_REPORT|wx.LC_NO_HEADER|wx.LC_SINGLE_SEL)
        self.list_ctrl.InsertColumn(0, "")
        self.list_ctrl.Bind(wx.EVT_LIST_ITEM_SELECTED, self._evt_list_item_selected)

        self.update_size()

    def update_suggestions(self, suggestions=None):
        suggestions = [] if suggestions == None else suggestions

        self.list_ctrl.DeleteAllItems()

        for i, entry in enumerate(suggestions):
            self.list_ctrl.InsertItem(i, entry)

        self.update_size()

    def update_size(self):
        item_count = self.list_ctrl.GetItemCount()
        item_extra_border = 0
        item_max_width = 0
        item_max_height = 0

        self.list_ctrl.SetColumnWidth(0, wx.LIST_AUTOSIZE)

        if item_count > 0:
            # Add some extra space to bottom and side of list_ctrl
            # so items have some "breathing room" and don't stick
            # to the panel's borders with every pixel.
            item_extra_border = 5
            item_max_width = self.list_ctrl.GetColumnWidth(0) + item_extra_border

            # Move the column's border outside the panel
            # so it doesn't show within the popup.
            self.list_ctrl.SetColumnWidth(0, item_max_width)

            # Note: All items in a ListCtrl seem to receive the same
            #       width and height when the ListCtrl is rendered.
            #       Fortunately they receive width and height of the
            #       biggest item in the ListCtrl, so we only need
            #       to check the first item for size calculations.
            item_max_height = self.list_ctrl.GetItemRect(0, wx.LIST_RECT_LABEL).GetHeight()

        # Calculate popup size from amount of items in the list_ctrl.
        list_ctrl_height = item_count * item_max_height + item_extra_border
        list_ctrl_size = (item_max_width, list_ctrl_height)

        self.SetSize(list_ctrl_size)
        self.panel.SetSize(list_ctrl_size)
        self.list_ctrl.SetSize(list_ctrl_size)

    def _evt_list_item_selected(self, event):
        if self.result_callback != None:
            self.result_callback(event.GetText())
        self.Show(False)



class SuggestionsTextCtrl(wx.Panel):
    def __init__(self, parent, max_entries=5, choices=None):
        wx.Panel.__init__(self, parent)

        self.choices = [] if choices == None else choices
        self.max_entries = max_entries

        text_ctrl_sizer = wx.BoxSizer(wx.HORIZONTAL)

        self.text_ctrl = wx.TextCtrl(self)
        self.text_ctrl.Bind(wx.EVT_TEXT, self._evt_text)
        text_ctrl_sizer.Add(self.text_ctrl, 1, wx.EXPAND)

        self.suggestions_popup = SuggestionsPopup(self, wx.BORDER_NONE, self.update_text_ctrl)

        self.SetSizer(text_ctrl_sizer)
        self.Layout()
        text_ctrl_sizer.Fit(self)

    def update_text_ctrl(self, text=None):
        if text == None:
            return

        # Set new value without emitting a wx.EVT_TEXT event.
        self.text_ctrl.ChangeValue(text)

    def _evt_text(self, event):
        text = self.text_ctrl.GetValue().lower()

        if len(text) > 0:
            matching_choices = []

            # First add all entries starting with entered text.
            for entry in self.choices:
                if entry.strip().lower().startswith(text):
                    matching_choices.append(entry)

                if len(matching_choices) >= self.max_entries:
                    break

            # If there is still room, also add entries
            # which include the string anywhere within them.
            if len(matching_choices) < self.max_entries:
                for entry in self.choices:
                    if text in entry.strip().lower() and entry not in matching_choices:
                        matching_choices.append(entry)

                    if len(matching_choices) >= self.max_entries:
                        break

            self.suggestions_popup.update_suggestions(matching_choices)

            # Show suggestions_popup below text_ctrl.
            # Update position here instead of __init__
            # in case the Panel is moved in between wx.EVT_TEXTs.
            pos = self.text_ctrl.ClientToScreen((0,0))
            sz =  self.text_ctrl.GetSize()
            self.suggestions_popup.Position(pos, (0, sz[1]))
            self.suggestions_popup.Show(True)
        else:
            self.suggestions_popup.Show(False)



class TrialPanel(wx.Panel):
    def __init__(self, *args, **kw):
        auto_completion_entries = [
            "The",
            "quick",
            "brown",
            "fox",
            "jumps",
            "over",
            "the",
            "lazy",
            "dog"
        ]

        wx.Panel.__init__(self, *args, **kw)

        self.SuggestionsTextCtrl = SuggestionsTextCtrl(self, max_entries=5, choices=auto_completion_entries)

        sizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(self.SuggestionsTextCtrl, 1)

        self.SetSizer(sizer)
        self.Layout()
        sizer.Fit(self)





if __name__ == '__main__':
    app = wx.App()
    frame = wx.Frame (None, -1, 'SuggestionsTextCtrl Demo', size=(400, 200))
    TrialPanel(frame)
    frame.Show()
    app.MainLoop()