├── screenshot.png ├── LICENSE ├── README.md └── actextcontrol.py /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RajaS/ACTextCtrl/HEAD/screenshot.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Raja Selvaraj 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Auto Complete TextCtrl for WxPython 2 | ----------------------------------- 3 | 4 | I am often using comboboxes on forms, but want something that allows 5 | more rapid data entry. This textctrl is designed to allow the user 6 | to quickly select from a list of choices by dynamically presenting 7 | matches in a dropdown below the textctrl. Similar implementations exist, 8 | notably http://wiki.wxpython.org/TextCtrlAutoComplete, from which a lot 9 | a lot of code is borrowed, but this is too complex for my needs. 10 | 11 | The widget is designed to present a textctrl into which the user starts 12 | typing. Matches (configurable to matches at beginning or matches anywhere) 13 | to the typed text will appear in a dropdown box. Up and down arrow keys can 14 | be used to navigate among the matches. Enter key will populate the textctrl 15 | with the selected match. Tab key will expand the entered text to the current 16 | match. When text is entered that does not have a match, an option exists to 17 | allow the user to add this text to the choices available. 18 | 19 | Note that this is still in the early changes. As of now, this has only been 20 | tested on Linux with Python 2.6 and 2.7 and WxPython 2.8. 21 | 22 | ![](https://github.com/RajaS/ACTextCtrl/raw/master/screenshot.png) -------------------------------------------------------------------------------- /actextcontrol.py: -------------------------------------------------------------------------------- 1 | 2 | # Written to satisfy my need for a text entry widget with autocomplete. 3 | # Heavily borrowed ideas from http://wiki.wxpython.org/TextCtrlAutoComplete 4 | # Raja Selvaraj 5 | 6 | 7 | # version 0.2 8 | # - Added option to use case sensitive matches, default is false 9 | 10 | # version 0.1 11 | 12 | import wx 13 | 14 | class ACTextControl(wx.TextCtrl): 15 | """ 16 | A Textcontrol that accepts a list of choices at the beginning. 17 | Choices are presented to the user based on string being entered. 18 | If a string outside the choices list is entered, option may 19 | be given for user to add it to list of choices. 20 | match_at_start - Should only choices beginning with text be shown ? 21 | add_option - Should user be able to add new choices 22 | case_sensitive - Only case sensitive matches 23 | """ 24 | def __init__(self, parent, candidates=[], match_at_start = False, 25 | add_option=False, case_sensitive=False): 26 | wx.TextCtrl.__init__(self, parent, style=wx.TE_PROCESS_ENTER) 27 | 28 | self.all_candidates = candidates 29 | self.match_at_start = match_at_start 30 | self.add_option = add_option 31 | self.case_sensitive = case_sensitive 32 | self.max_candidates = 5 # maximum no. of candidates to show 33 | self.select_candidates = [] 34 | self.popup = ACPopup(self) 35 | 36 | self._set_bindings() 37 | 38 | self._screenheight = wx.SystemSettings.GetMetric(wx.SYS_SCREEN_Y) 39 | self._popdown = True # Does the popup go down from the textctrl ? 40 | 41 | def _set_bindings(self): 42 | """ 43 | One place to setup all the bindings 44 | """ 45 | # text entry triggers update of the popup window 46 | self.Bind(wx.EVT_TEXT, self._on_text, self) 47 | self.Bind(wx.EVT_KEY_DOWN, self._on_key_down, self) 48 | 49 | # loss of focus should hide the popup 50 | self.Bind(wx.EVT_KILL_FOCUS, self._on_focus_loss) 51 | self.Bind(wx.EVT_SET_FOCUS, self._on_focus) 52 | 53 | 54 | def SetValue(self, value): 55 | """ 56 | Directly calling setvalue triggers textevent 57 | which results in popup appearing. 58 | To avoid this, call changevalue 59 | """ 60 | super(ACTextControl, self).ChangeValue(value) 61 | 62 | 63 | def _on_text(self, event): 64 | """ 65 | On text entry in the textctrl, 66 | Pop up the popup, 67 | or update candidates if its already visible 68 | """ 69 | txt = self.GetValue() 70 | 71 | # if txt is empty (after backspace), hide popup 72 | if not txt: 73 | if self.popup.IsShown: 74 | self.popup.Show(False) 75 | event.Skip() 76 | return 77 | 78 | # select candidates 79 | if self.match_at_start and self.case_sensitive: 80 | self.select_candidates = [ch for ch in self.all_candidates 81 | if ch.startswith(txt)] 82 | elif self.match_at_start and not self.case_sensitive: 83 | self.select_candidates = [ch for ch in self.all_candidates 84 | if ch.lower().startswith(txt.lower())] 85 | elif self.case_sensitive and not self.match_at_start: 86 | self.select_candidates = [ch for ch in self.all_candidates if txt in ch] 87 | else: 88 | self.select_candidates = [ch for ch in self.all_candidates if txt.lower() in ch.lower()] 89 | 90 | if len(self.select_candidates) == 0: 91 | if not self.add_option: 92 | if self.popup.IsShown(): 93 | self.popup.Show(False) 94 | 95 | else: 96 | display = ['Add ' + txt] 97 | self.popup._set_candidates(display, 'Add') 98 | self._resize_popup(display, txt) 99 | self._position_popup() 100 | if not self.popup.IsShown(): 101 | self.popup.Show() 102 | 103 | else: 104 | self._show_popup(self.select_candidates, txt) 105 | 106 | 107 | def _show_popup(self, candidates, txt): 108 | # set up the popup and bring it on 109 | self._resize_popup(candidates, txt) 110 | self._position_popup() 111 | 112 | candidates.sort() 113 | 114 | if self._popdown: 115 | # TODO: Allow custom ordering 116 | self.popup._set_candidates(candidates, txt) 117 | self.popup.candidatebox.SetSelection(0) 118 | 119 | else: 120 | candidates.reverse() 121 | self.popup._set_candidates(candidates, txt) 122 | self.popup.candidatebox.SetSelection(len(candidates)-1) 123 | 124 | if not self.popup.IsShown(): 125 | self.popup.Show() 126 | 127 | 128 | 129 | def _on_focus_loss(self, event): 130 | """Close the popup when focus is lost""" 131 | if self.popup.IsShown(): 132 | self.popup.Show(False) 133 | 134 | 135 | def _on_focus(self, event): 136 | """ 137 | When focus is gained, 138 | if empty, show all candidates, 139 | else, show matches 140 | """ 141 | txt = self.GetValue() 142 | if txt == '': 143 | self.select_candidates = self.all_candidates 144 | self._show_popup(self.all_candidates, '') 145 | else: 146 | self._on_text(event) 147 | 148 | 149 | def _position_popup(self): 150 | """Calculate position for popup and 151 | display it""" 152 | left_x, upper_y = self.GetScreenPositionTuple() 153 | _, height = self.GetSizeTuple() 154 | popup_width, popup_height = self.popupsize 155 | 156 | if upper_y + height + popup_height > self._screenheight: 157 | self._popdown = False 158 | self.popup.SetPosition((left_x, upper_y - popup_height)) 159 | else: 160 | self._popdown = True 161 | self.popup.SetPosition((left_x, upper_y + height)) 162 | 163 | 164 | def _resize_popup(self, candidates, entered_txt): 165 | """Calculate the size for the popup to 166 | accomodate the selected candidates""" 167 | # Handle empty list (no matching candidates) 168 | if len(candidates) == 0: 169 | candidate_count = 3.5 # one line 170 | longest = len(entered_txt) + 4 + 4 #4 for 'Add ' 171 | 172 | else: 173 | # additional 3 lines needed to show all candidates without scrollbar 174 | candidate_count = min(self.max_candidates, len(candidates)) + 2.5 175 | longest = max([len(candidate) for candidate in candidates]) + 4 176 | 177 | 178 | charheight = self.popup.candidatebox.GetCharHeight() 179 | charwidth = self.popup.candidatebox.GetCharWidth() 180 | 181 | self.popupsize = wx.Size( charwidth*longest, charheight*candidate_count ) 182 | 183 | self.popup.candidatebox.SetSize(self.popupsize) 184 | self.popup.SetClientSize(self.popupsize) 185 | 186 | 187 | def _on_key_down(self, event): 188 | """Handle key presses. 189 | Special keys are handled appropriately. 190 | For other keys, the event is skipped and allowed 191 | to be caught by ontext event""" 192 | skip = True 193 | visible = self.popup.IsShown() 194 | sel = self.popup.candidatebox.GetSelection() 195 | 196 | # Escape key closes the popup if it is visible 197 | if event.GetKeyCode() == wx.WXK_ESCAPE: 198 | if visible: 199 | self.popup.Show(False) 200 | 201 | # Down key for navigation in list of candidates 202 | elif event.GetKeyCode() == wx.WXK_DOWN: 203 | if not visible: 204 | skip = False 205 | pass 206 | # 207 | if sel + 1 < self.popup.candidatebox.GetItemCount(): 208 | self.popup.candidatebox.SetSelection(sel + 1) 209 | else: 210 | skip = False 211 | 212 | # Up key for navigation in list of candidates 213 | elif event.GetKeyCode() == wx.WXK_UP: 214 | if not visible: 215 | skip = False 216 | pass 217 | if sel > -1: 218 | self.popup.candidatebox.SetSelection(sel - 1) 219 | else: 220 | skip = False 221 | 222 | # Enter - use current selection for text 223 | elif event.GetKeyCode() == wx.WXK_RETURN: 224 | if not visible: 225 | #TODO: trigger event? 226 | pass 227 | # Add option is only displayed 228 | elif len(self.select_candidates) == 0: 229 | if self.popup.candidatebox.GetSelection() == 0: 230 | self.all_candidates.append(self.GetValue()) 231 | self.popup.Show(False) 232 | 233 | elif self.popup.candidatebox.GetSelection() == -1: 234 | self.popup.Show(False) 235 | 236 | elif self.popup.candidatebox.GetSelection() > -1: 237 | self.SetValue(self.select_candidates[self.popup.candidatebox.GetSelection()]) 238 | self.SetInsertionPointEnd() 239 | self.popup.Show(False) 240 | 241 | # Tab - set selected choice as text 242 | elif event.GetKeyCode() == wx.WXK_TAB: 243 | if visible: 244 | self.SetValue(self.select_candidates[self.popup.candidatebox.GetSelection()]) 245 | # set cursor at end of text 246 | self.SetInsertionPointEnd() 247 | skip = False 248 | 249 | if skip: 250 | event.Skip() 251 | 252 | 253 | def get_choices(self): 254 | """Return the current choices. 255 | Useful if choices have been added by the user""" 256 | return self.all_candidates 257 | 258 | 259 | 260 | 261 | class ACPopup(wx.PopupWindow): 262 | """ 263 | The popup that displays the candidates for 264 | autocompleting the current text in the textctrl 265 | """ 266 | def __init__(self, parent): 267 | wx.PopupWindow.__init__(self, parent) 268 | self.candidatebox = wx.SimpleHtmlListBox(self, -1, choices=[]) 269 | self.SetSize((100, 100)) 270 | self.displayed_candidates = [] 271 | 272 | def _set_candidates(self, candidates, txt): 273 | """ 274 | Clear existing candidates and use the supplied candidates 275 | Candidates is a list of strings. 276 | """ 277 | # if there is no change, do not update 278 | if candidates == sorted(self.displayed_candidates): 279 | pass 280 | 281 | # Remove the current candidates 282 | self.candidatebox.Clear() 283 | 284 | #self.candidatebox.Append(['test', 'test']) 285 | for ch in candidates: 286 | self.candidatebox.Append(self._htmlformat(ch, txt)) 287 | 288 | self.displayed_candidates = candidates 289 | 290 | 291 | def _htmlformat(self, text, substring): 292 | """ 293 | For displaying in the popup, format the text 294 | to highlight the substring in html 295 | """ 296 | # empty substring 297 | if len(substring) == 0: 298 | return text 299 | 300 | else: 301 | return text.replace(substring, '' + substring + '', 1) 302 | 303 | 304 | def test(): 305 | app = wx.PySimpleApp() 306 | frm = wx.Frame(None, -1, "Test", style=wx.DEFAULT_FRAME_STYLE) 307 | panel = wx.Panel(frm) 308 | 309 | candidates = ['cat', 'Cow', 'dog', 'rat', 'Raccoon', 'pig', 310 | 'tiger', 'elephant', 'ant', 311 | 'horse', 'Anteater', 'giraffe'] 312 | 313 | label1 = wx.StaticText(panel, -1, 'Matches anywhere in string') 314 | label2 = wx.StaticText(panel, -1, 'Matches only at beginning') 315 | label3 = wx.StaticText(panel, -1, 'Matches at beginning, case sensitive') 316 | label4 = wx.StaticText(panel, -1, 'Allows new candidates to be added') 317 | 318 | ctrl1 = ACTextControl(panel, candidates=candidates, add_option=False) 319 | ctrl2 = ACTextControl(panel, candidates=candidates, match_at_start=True, add_option=False) 320 | ctrl3 = ACTextControl(panel, candidates=candidates, match_at_start=True, 321 | add_option=False, case_sensitive=True) 322 | ctrl4 = ACTextControl(panel, candidates=candidates, add_option=True) 323 | 324 | 325 | fgsizer = wx.FlexGridSizer(rows=4, cols=2, vgap=20, hgap=10) 326 | fgsizer.AddMany([label1, ctrl1, 327 | label2, ctrl2, 328 | label3, ctrl3, 329 | label4, ctrl4]) 330 | 331 | panel.SetAutoLayout(True) 332 | panel.SetSizer(fgsizer) 333 | fgsizer.Fit(panel) 334 | 335 | panel.Layout() 336 | app.SetTopWindow(frm) 337 | frm.SetSize((400, 250)) 338 | frm.Show() 339 | 340 | ctrl1.SetValue('cat') 341 | 342 | app.MainLoop() 343 | 344 | 345 | 346 | if __name__ == '__main__': 347 | test() 348 | --------------------------------------------------------------------------------