Home | History | Annotate | Download | only in idlelib
      1 """
      2 An auto-completion window for IDLE, used by the AutoComplete extension
      3 """
      4 from Tkinter import *
      5 from idlelib.MultiCall import MC_SHIFT
      6 from idlelib.AutoComplete import COMPLETE_FILES, COMPLETE_ATTRIBUTES
      7 
      8 HIDE_VIRTUAL_EVENT_NAME = "<<autocompletewindow-hide>>"
      9 HIDE_SEQUENCES = ("<FocusOut>", "<ButtonPress>")
     10 KEYPRESS_VIRTUAL_EVENT_NAME = "<<autocompletewindow-keypress>>"
     11 # We need to bind event beyond <Key> so that the function will be called
     12 # before the default specific IDLE function
     13 KEYPRESS_SEQUENCES = ("<Key>", "<Key-BackSpace>", "<Key-Return>", "<Key-Tab>",
     14                       "<Key-Up>", "<Key-Down>", "<Key-Home>", "<Key-End>",
     15                       "<Key-Prior>", "<Key-Next>")
     16 KEYRELEASE_VIRTUAL_EVENT_NAME = "<<autocompletewindow-keyrelease>>"
     17 KEYRELEASE_SEQUENCE = "<KeyRelease>"
     18 LISTUPDATE_SEQUENCE = "<B1-ButtonRelease>"
     19 WINCONFIG_SEQUENCE = "<Configure>"
     20 DOUBLECLICK_SEQUENCE = "<B1-Double-ButtonRelease>"
     21 
     22 class AutoCompleteWindow:
     23 
     24     def __init__(self, widget):
     25         # The widget (Text) on which we place the AutoCompleteWindow
     26         self.widget = widget
     27         # The widgets we create
     28         self.autocompletewindow = self.listbox = self.scrollbar = None
     29         # The default foreground and background of a selection. Saved because
     30         # they are changed to the regular colors of list items when the
     31         # completion start is not a prefix of the selected completion
     32         self.origselforeground = self.origselbackground = None
     33         # The list of completions
     34         self.completions = None
     35         # A list with more completions, or None
     36         self.morecompletions = None
     37         # The completion mode. Either AutoComplete.COMPLETE_ATTRIBUTES or
     38         # AutoComplete.COMPLETE_FILES
     39         self.mode = None
     40         # The current completion start, on the text box (a string)
     41         self.start = None
     42         # The index of the start of the completion
     43         self.startindex = None
     44         # The last typed start, used so that when the selection changes,
     45         # the new start will be as close as possible to the last typed one.
     46         self.lasttypedstart = None
     47         # Do we have an indication that the user wants the completion window
     48         # (for example, he clicked the list)
     49         self.userwantswindow = None
     50         # event ids
     51         self.hideid = self.keypressid = self.listupdateid = self.winconfigid \
     52         = self.keyreleaseid = self.doubleclickid                         = None
     53         # Flag set if last keypress was a tab
     54         self.lastkey_was_tab = False
     55 
     56     def _change_start(self, newstart):
     57         min_len = min(len(self.start), len(newstart))
     58         i = 0
     59         while i < min_len and self.start[i] == newstart[i]:
     60             i += 1
     61         if i < len(self.start):
     62             self.widget.delete("%s+%dc" % (self.startindex, i),
     63                                "%s+%dc" % (self.startindex, len(self.start)))
     64         if i < len(newstart):
     65             self.widget.insert("%s+%dc" % (self.startindex, i),
     66                                newstart[i:])
     67         self.start = newstart
     68 
     69     def _binary_search(self, s):
     70         """Find the first index in self.completions where completions[i] is
     71         greater or equal to s, or the last index if there is no such
     72         one."""
     73         i = 0; j = len(self.completions)
     74         while j > i:
     75             m = (i + j) // 2
     76             if self.completions[m] >= s:
     77                 j = m
     78             else:
     79                 i = m + 1
     80         return min(i, len(self.completions)-1)
     81 
     82     def _complete_string(self, s):
     83         """Assuming that s is the prefix of a string in self.completions,
     84         return the longest string which is a prefix of all the strings which
     85         s is a prefix of them. If s is not a prefix of a string, return s."""
     86         first = self._binary_search(s)
     87         if self.completions[first][:len(s)] != s:
     88             # There is not even one completion which s is a prefix of.
     89             return s
     90         # Find the end of the range of completions where s is a prefix of.
     91         i = first + 1
     92         j = len(self.completions)
     93         while j > i:
     94             m = (i + j) // 2
     95             if self.completions[m][:len(s)] != s:
     96                 j = m
     97             else:
     98                 i = m + 1
     99         last = i-1
    100 
    101         if first == last: # only one possible completion
    102             return self.completions[first]
    103 
    104         # We should return the maximum prefix of first and last
    105         first_comp = self.completions[first]
    106         last_comp = self.completions[last]
    107         min_len = min(len(first_comp), len(last_comp))
    108         i = len(s)
    109         while i < min_len and first_comp[i] == last_comp[i]:
    110             i += 1
    111         return first_comp[:i]
    112 
    113     def _selection_changed(self):
    114         """Should be called when the selection of the Listbox has changed.
    115         Updates the Listbox display and calls _change_start."""
    116         cursel = int(self.listbox.curselection()[0])
    117 
    118         self.listbox.see(cursel)
    119 
    120         lts = self.lasttypedstart
    121         selstart = self.completions[cursel]
    122         if self._binary_search(lts) == cursel:
    123             newstart = lts
    124         else:
    125             min_len = min(len(lts), len(selstart))
    126             i = 0
    127             while i < min_len and lts[i] == selstart[i]:
    128                 i += 1
    129             newstart = selstart[:i]
    130         self._change_start(newstart)
    131 
    132         if self.completions[cursel][:len(self.start)] == self.start:
    133             # start is a prefix of the selected completion
    134             self.listbox.configure(selectbackground=self.origselbackground,
    135                                    selectforeground=self.origselforeground)
    136         else:
    137             self.listbox.configure(selectbackground=self.listbox.cget("bg"),
    138                                    selectforeground=self.listbox.cget("fg"))
    139             # If there are more completions, show them, and call me again.
    140             if self.morecompletions:
    141                 self.completions = self.morecompletions
    142                 self.morecompletions = None
    143                 self.listbox.delete(0, END)
    144                 for item in self.completions:
    145                     self.listbox.insert(END, item)
    146                 self.listbox.select_set(self._binary_search(self.start))
    147                 self._selection_changed()
    148 
    149     def show_window(self, comp_lists, index, complete, mode, userWantsWin):
    150         """Show the autocomplete list, bind events.
    151         If complete is True, complete the text, and if there is exactly one
    152         matching completion, don't open a list."""
    153         # Handle the start we already have
    154         self.completions, self.morecompletions = comp_lists
    155         self.mode = mode
    156         self.startindex = self.widget.index(index)
    157         self.start = self.widget.get(self.startindex, "insert")
    158         if complete:
    159             completed = self._complete_string(self.start)
    160             self._change_start(completed)
    161             i = self._binary_search(completed)
    162             if self.completions[i] == completed and \
    163                (i == len(self.completions)-1 or
    164                 self.completions[i+1][:len(completed)] != completed):
    165                 # There is exactly one matching completion
    166                 return
    167         self.userwantswindow = userWantsWin
    168         self.lasttypedstart = self.start
    169 
    170         # Put widgets in place
    171         self.autocompletewindow = acw = Toplevel(self.widget)
    172         # Put it in a position so that it is not seen.
    173         acw.wm_geometry("+10000+10000")
    174         # Make it float
    175         acw.wm_overrideredirect(1)
    176         try:
    177             # This command is only needed and available on Tk >= 8.4.0 for OSX
    178             # Without it, call tips intrude on the typing process by grabbing
    179             # the focus.
    180             acw.tk.call("::tk::unsupported::MacWindowStyle", "style", acw._w,
    181                         "help", "noActivates")
    182         except TclError:
    183             pass
    184         self.scrollbar = scrollbar = Scrollbar(acw, orient=VERTICAL)
    185         self.listbox = listbox = Listbox(acw, yscrollcommand=scrollbar.set,
    186                                          exportselection=False, bg="white")
    187         for item in self.completions:
    188             listbox.insert(END, item)
    189         self.origselforeground = listbox.cget("selectforeground")
    190         self.origselbackground = listbox.cget("selectbackground")
    191         scrollbar.config(command=listbox.yview)
    192         scrollbar.pack(side=RIGHT, fill=Y)
    193         listbox.pack(side=LEFT, fill=BOTH, expand=True)
    194 
    195         # Initialize the listbox selection
    196         self.listbox.select_set(self._binary_search(self.start))
    197         self._selection_changed()
    198 
    199         # bind events
    200         self.hideid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME,
    201                                        self.hide_event)
    202         for seq in HIDE_SEQUENCES:
    203             self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq)
    204         self.keypressid = self.widget.bind(KEYPRESS_VIRTUAL_EVENT_NAME,
    205                                            self.keypress_event)
    206         for seq in KEYPRESS_SEQUENCES:
    207             self.widget.event_add(KEYPRESS_VIRTUAL_EVENT_NAME, seq)
    208         self.keyreleaseid = self.widget.bind(KEYRELEASE_VIRTUAL_EVENT_NAME,
    209                                              self.keyrelease_event)
    210         self.widget.event_add(KEYRELEASE_VIRTUAL_EVENT_NAME,KEYRELEASE_SEQUENCE)
    211         self.listupdateid = listbox.bind(LISTUPDATE_SEQUENCE,
    212                                          self.listselect_event)
    213         self.winconfigid = acw.bind(WINCONFIG_SEQUENCE, self.winconfig_event)
    214         self.doubleclickid = listbox.bind(DOUBLECLICK_SEQUENCE,
    215                                           self.doubleclick_event)
    216 
    217     def winconfig_event(self, event):
    218         if not self.is_active():
    219             return
    220         # Position the completion list window
    221         text = self.widget
    222         text.see(self.startindex)
    223         x, y, cx, cy = text.bbox(self.startindex)
    224         acw = self.autocompletewindow
    225         acw_width, acw_height = acw.winfo_width(), acw.winfo_height()
    226         text_width, text_height = text.winfo_width(), text.winfo_height()
    227         new_x = text.winfo_rootx() + min(x, max(0, text_width - acw_width))
    228         new_y = text.winfo_rooty() + y
    229         if (text_height - (y + cy) >= acw_height # enough height below
    230             or y < acw_height): # not enough height above
    231             # place acw below current line
    232             new_y += cy
    233         else:
    234             # place acw above current line
    235             new_y -= acw_height
    236         acw.wm_geometry("+%d+%d" % (new_x, new_y))
    237 
    238     def hide_event(self, event):
    239         if not self.is_active():
    240             return
    241         self.hide_window()
    242 
    243     def listselect_event(self, event):
    244         if not self.is_active():
    245             return
    246         self.userwantswindow = True
    247         cursel = int(self.listbox.curselection()[0])
    248         self._change_start(self.completions[cursel])
    249 
    250     def doubleclick_event(self, event):
    251         # Put the selected completion in the text, and close the list
    252         cursel = int(self.listbox.curselection()[0])
    253         self._change_start(self.completions[cursel])
    254         self.hide_window()
    255 
    256     def keypress_event(self, event):
    257         if not self.is_active():
    258             return
    259         keysym = event.keysym
    260         if hasattr(event, "mc_state"):
    261             state = event.mc_state
    262         else:
    263             state = 0
    264         if keysym != "Tab":
    265             self.lastkey_was_tab = False
    266         if (len(keysym) == 1 or keysym in ("underscore", "BackSpace")
    267             or (self.mode == COMPLETE_FILES and keysym in
    268                 ("period", "minus"))) \
    269            and not (state & ~MC_SHIFT):
    270             # Normal editing of text
    271             if len(keysym) == 1:
    272                 self._change_start(self.start + keysym)
    273             elif keysym == "underscore":
    274                 self._change_start(self.start + '_')
    275             elif keysym == "period":
    276                 self._change_start(self.start + '.')
    277             elif keysym == "minus":
    278                 self._change_start(self.start + '-')
    279             else:
    280                 # keysym == "BackSpace"
    281                 if len(self.start) == 0:
    282                     self.hide_window()
    283                     return
    284                 self._change_start(self.start[:-1])
    285             self.lasttypedstart = self.start
    286             self.listbox.select_clear(0, int(self.listbox.curselection()[0]))
    287             self.listbox.select_set(self._binary_search(self.start))
    288             self._selection_changed()
    289             return "break"
    290 
    291         elif keysym == "Return":
    292             self.hide_window()
    293             return
    294 
    295         elif (self.mode == COMPLETE_ATTRIBUTES and keysym in
    296               ("period", "space", "parenleft", "parenright", "bracketleft",
    297                "bracketright")) or \
    298              (self.mode == COMPLETE_FILES and keysym in
    299               ("slash", "backslash", "quotedbl", "apostrophe")) \
    300              and not (state & ~MC_SHIFT):
    301             # If start is a prefix of the selection, but is not '' when
    302             # completing file names, put the whole
    303             # selected completion. Anyway, close the list.
    304             cursel = int(self.listbox.curselection()[0])
    305             if self.completions[cursel][:len(self.start)] == self.start \
    306                and (self.mode == COMPLETE_ATTRIBUTES or self.start):
    307                 self._change_start(self.completions[cursel])
    308             self.hide_window()
    309             return
    310 
    311         elif keysym in ("Home", "End", "Prior", "Next", "Up", "Down") and \
    312              not state:
    313             # Move the selection in the listbox
    314             self.userwantswindow = True
    315             cursel = int(self.listbox.curselection()[0])
    316             if keysym == "Home":
    317                 newsel = 0
    318             elif keysym == "End":
    319                 newsel = len(self.completions)-1
    320             elif keysym in ("Prior", "Next"):
    321                 jump = self.listbox.nearest(self.listbox.winfo_height()) - \
    322                        self.listbox.nearest(0)
    323                 if keysym == "Prior":
    324                     newsel = max(0, cursel-jump)
    325                 else:
    326                     assert keysym == "Next"
    327                     newsel = min(len(self.completions)-1, cursel+jump)
    328             elif keysym == "Up":
    329                 newsel = max(0, cursel-1)
    330             else:
    331                 assert keysym == "Down"
    332                 newsel = min(len(self.completions)-1, cursel+1)
    333             self.listbox.select_clear(cursel)
    334             self.listbox.select_set(newsel)
    335             self._selection_changed()
    336             self._change_start(self.completions[newsel])
    337             return "break"
    338 
    339         elif (keysym == "Tab" and not state):
    340             if self.lastkey_was_tab:
    341                 # two tabs in a row; insert current selection and close acw
    342                 cursel = int(self.listbox.curselection()[0])
    343                 self._change_start(self.completions[cursel])
    344                 self.hide_window()
    345                 return "break"
    346             else:
    347                 # first tab; let AutoComplete handle the completion
    348                 self.userwantswindow = True
    349                 self.lastkey_was_tab = True
    350                 return
    351 
    352         elif any(s in keysym for s in ("Shift", "Control", "Alt",
    353                                        "Meta", "Command", "Option")):
    354             # A modifier key, so ignore
    355             return
    356 
    357         else:
    358             # Unknown event, close the window and let it through.
    359             self.hide_window()
    360             return
    361 
    362     def keyrelease_event(self, event):
    363         if not self.is_active():
    364             return
    365         if self.widget.index("insert") != \
    366            self.widget.index("%s+%dc" % (self.startindex, len(self.start))):
    367             # If we didn't catch an event which moved the insert, close window
    368             self.hide_window()
    369 
    370     def is_active(self):
    371         return self.autocompletewindow is not None
    372 
    373     def complete(self):
    374         self._change_start(self._complete_string(self.start))
    375         # The selection doesn't change.
    376 
    377     def hide_window(self):
    378         if not self.is_active():
    379             return
    380 
    381         # unbind events
    382         for seq in HIDE_SEQUENCES:
    383             self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq)
    384         self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideid)
    385         self.hideid = None
    386         for seq in KEYPRESS_SEQUENCES:
    387             self.widget.event_delete(KEYPRESS_VIRTUAL_EVENT_NAME, seq)
    388         self.widget.unbind(KEYPRESS_VIRTUAL_EVENT_NAME, self.keypressid)
    389         self.keypressid = None
    390         self.widget.event_delete(KEYRELEASE_VIRTUAL_EVENT_NAME,
    391                                  KEYRELEASE_SEQUENCE)
    392         self.widget.unbind(KEYRELEASE_VIRTUAL_EVENT_NAME, self.keyreleaseid)
    393         self.keyreleaseid = None
    394         self.listbox.unbind(LISTUPDATE_SEQUENCE, self.listupdateid)
    395         self.listupdateid = None
    396         self.autocompletewindow.unbind(WINCONFIG_SEQUENCE, self.winconfigid)
    397         self.winconfigid = None
    398 
    399         # destroy widgets
    400         self.scrollbar.destroy()
    401         self.scrollbar = None
    402         self.listbox.destroy()
    403         self.listbox = None
    404         self.autocompletewindow.destroy()
    405         self.autocompletewindow = None
    406