• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""
2Dialog for building Tkinter accelerator key bindings
3"""
4from tkinter import Toplevel, Listbox, StringVar, TclError
5from tkinter.ttk import Frame, Button, Checkbutton, Entry, Label, Scrollbar
6from tkinter import messagebox
7from tkinter.simpledialog import _setup_dialog
8import string
9import sys
10
11
12FUNCTION_KEYS = ('F1', 'F2' ,'F3' ,'F4' ,'F5' ,'F6',
13                 'F7', 'F8' ,'F9' ,'F10' ,'F11' ,'F12')
14ALPHANUM_KEYS = tuple(string.ascii_lowercase + string.digits)
15PUNCTUATION_KEYS = tuple('~!@#%^&*()_-+={}[]|;:,.<>/?')
16WHITESPACE_KEYS = ('Tab', 'Space', 'Return')
17EDIT_KEYS = ('BackSpace', 'Delete', 'Insert')
18MOVE_KEYS = ('Home', 'End', 'Page Up', 'Page Down', 'Left Arrow',
19             'Right Arrow', 'Up Arrow', 'Down Arrow')
20AVAILABLE_KEYS = (ALPHANUM_KEYS + PUNCTUATION_KEYS + FUNCTION_KEYS +
21                  WHITESPACE_KEYS + EDIT_KEYS + MOVE_KEYS)
22
23
24def translate_key(key, modifiers):
25    "Translate from keycap symbol to the Tkinter keysym."
26    mapping = {'Space':'space',
27            '~':'asciitilde', '!':'exclam', '@':'at', '#':'numbersign',
28            '%':'percent', '^':'asciicircum', '&':'ampersand',
29            '*':'asterisk', '(':'parenleft', ')':'parenright',
30            '_':'underscore', '-':'minus', '+':'plus', '=':'equal',
31            '{':'braceleft', '}':'braceright',
32            '[':'bracketleft', ']':'bracketright', '|':'bar',
33            ';':'semicolon', ':':'colon', ',':'comma', '.':'period',
34            '<':'less', '>':'greater', '/':'slash', '?':'question',
35            'Page Up':'Prior', 'Page Down':'Next',
36            'Left Arrow':'Left', 'Right Arrow':'Right',
37            'Up Arrow':'Up', 'Down Arrow': 'Down', 'Tab':'Tab'}
38    key = mapping.get(key, key)
39    if 'Shift' in modifiers and key in string.ascii_lowercase:
40        key = key.upper()
41    return f'Key-{key}'
42
43
44class GetKeysDialog(Toplevel):
45
46    # Dialog title for invalid key sequence
47    keyerror_title = 'Key Sequence Error'
48
49    def __init__(self, parent, title, action, current_key_sequences,
50                 *, _htest=False, _utest=False):
51        """
52        parent - parent of this dialog
53        title - string which is the title of the popup dialog
54        action - string, the name of the virtual event these keys will be
55                 mapped to
56        current_key_sequences - list, a list of all key sequence lists
57                 currently mapped to virtual events, for overlap checking
58        _htest - bool, change box location when running htest
59        _utest - bool, do not wait when running unittest
60        """
61        Toplevel.__init__(self, parent)
62        self.withdraw()  # Hide while setting geometry.
63        self.configure(borderwidth=5)
64        self.resizable(height=False, width=False)
65        self.title(title)
66        self.transient(parent)
67        _setup_dialog(self)
68        self.grab_set()
69        self.protocol("WM_DELETE_WINDOW", self.cancel)
70        self.parent = parent
71        self.action = action
72        self.current_key_sequences = current_key_sequences
73        self.result = ''
74        self.key_string = StringVar(self)
75        self.key_string.set('')
76        # Set self.modifiers, self.modifier_label.
77        self.set_modifiers_for_platform()
78        self.modifier_vars = []
79        for modifier in self.modifiers:
80            variable = StringVar(self)
81            variable.set('')
82            self.modifier_vars.append(variable)
83        self.advanced = False
84        self.create_widgets()
85        self.update_idletasks()
86        self.geometry(
87                "+%d+%d" % (
88                    parent.winfo_rootx() +
89                    (parent.winfo_width()/2 - self.winfo_reqwidth()/2),
90                    parent.winfo_rooty() +
91                    ((parent.winfo_height()/2 - self.winfo_reqheight()/2)
92                    if not _htest else 150)
93                ) )  # Center dialog over parent (or below htest box).
94        if not _utest:
95            self.deiconify()  # Geometry set, unhide.
96            self.wait_window()
97
98    def showerror(self, *args, **kwargs):
99        # Make testing easier.  Replace in #30751.
100        messagebox.showerror(*args, **kwargs)
101
102    def create_widgets(self):
103        self.frame = frame = Frame(self, borderwidth=2, relief='sunken')
104        frame.pack(side='top', expand=True, fill='both')
105
106        frame_buttons = Frame(self)
107        frame_buttons.pack(side='bottom', fill='x')
108
109        self.button_ok = Button(frame_buttons, text='OK',
110                                width=8, command=self.ok)
111        self.button_ok.grid(row=0, column=0, padx=5, pady=5)
112        self.button_cancel = Button(frame_buttons, text='Cancel',
113                                   width=8, command=self.cancel)
114        self.button_cancel.grid(row=0, column=1, padx=5, pady=5)
115
116        # Basic entry key sequence.
117        self.frame_keyseq_basic = Frame(frame, name='keyseq_basic')
118        self.frame_keyseq_basic.grid(row=0, column=0, sticky='nsew',
119                                      padx=5, pady=5)
120        basic_title = Label(self.frame_keyseq_basic,
121                            text=f"New keys for '{self.action}' :")
122        basic_title.pack(anchor='w')
123
124        basic_keys = Label(self.frame_keyseq_basic, justify='left',
125                           textvariable=self.key_string, relief='groove',
126                           borderwidth=2)
127        basic_keys.pack(ipadx=5, ipady=5, fill='x')
128
129        # Basic entry controls.
130        self.frame_controls_basic = Frame(frame)
131        self.frame_controls_basic.grid(row=1, column=0, sticky='nsew', padx=5)
132
133        # Basic entry modifiers.
134        self.modifier_checkbuttons = {}
135        column = 0
136        for modifier, variable in zip(self.modifiers, self.modifier_vars):
137            label = self.modifier_label.get(modifier, modifier)
138            check = Checkbutton(self.frame_controls_basic,
139                                command=self.build_key_string, text=label,
140                                variable=variable, onvalue=modifier, offvalue='')
141            check.grid(row=0, column=column, padx=2, sticky='w')
142            self.modifier_checkbuttons[modifier] = check
143            column += 1
144
145        # Basic entry help text.
146        help_basic = Label(self.frame_controls_basic, justify='left',
147                           text="Select the desired modifier keys\n"+
148                                "above, and the final key from the\n"+
149                                "list on the right.\n\n" +
150                                "Use upper case Symbols when using\n" +
151                                "the Shift modifier.  (Letters will be\n" +
152                                "converted automatically.)")
153        help_basic.grid(row=1, column=0, columnspan=4, padx=2, sticky='w')
154
155        # Basic entry key list.
156        self.list_keys_final = Listbox(self.frame_controls_basic, width=15,
157                                       height=10, selectmode='single')
158        self.list_keys_final.insert('end', *AVAILABLE_KEYS)
159        self.list_keys_final.bind('<ButtonRelease-1>', self.final_key_selected)
160        self.list_keys_final.grid(row=0, column=4, rowspan=4, sticky='ns')
161        scroll_keys_final = Scrollbar(self.frame_controls_basic,
162                                      orient='vertical',
163                                      command=self.list_keys_final.yview)
164        self.list_keys_final.config(yscrollcommand=scroll_keys_final.set)
165        scroll_keys_final.grid(row=0, column=5, rowspan=4, sticky='ns')
166        self.button_clear = Button(self.frame_controls_basic,
167                                   text='Clear Keys',
168                                   command=self.clear_key_seq)
169        self.button_clear.grid(row=2, column=0, columnspan=4)
170
171        # Advanced entry key sequence.
172        self.frame_keyseq_advanced = Frame(frame, name='keyseq_advanced')
173        self.frame_keyseq_advanced.grid(row=0, column=0, sticky='nsew',
174                                         padx=5, pady=5)
175        advanced_title = Label(self.frame_keyseq_advanced, justify='left',
176                               text=f"Enter new binding(s) for '{self.action}' :\n" +
177                                     "(These bindings will not be checked for validity!)")
178        advanced_title.pack(anchor='w')
179        self.advanced_keys = Entry(self.frame_keyseq_advanced,
180                                   textvariable=self.key_string)
181        self.advanced_keys.pack(fill='x')
182
183        # Advanced entry help text.
184        self.frame_help_advanced = Frame(frame)
185        self.frame_help_advanced.grid(row=1, column=0, sticky='nsew', padx=5)
186        help_advanced = Label(self.frame_help_advanced, justify='left',
187            text="Key bindings are specified using Tkinter keysyms as\n"+
188                 "in these samples: <Control-f>, <Shift-F2>, <F12>,\n"
189                 "<Control-space>, <Meta-less>, <Control-Alt-Shift-X>.\n"
190                 "Upper case is used when the Shift modifier is present!\n\n" +
191                 "'Emacs style' multi-keystroke bindings are specified as\n" +
192                 "follows: <Control-x><Control-y>, where the first key\n" +
193                 "is the 'do-nothing' keybinding.\n\n" +
194                 "Multiple separate bindings for one action should be\n"+
195                 "separated by a space, eg., <Alt-v> <Meta-v>." )
196        help_advanced.grid(row=0, column=0, sticky='nsew')
197
198        # Switch between basic and advanced.
199        self.button_level = Button(frame, command=self.toggle_level,
200                                  text='<< Basic Key Binding Entry')
201        self.button_level.grid(row=2, column=0, stick='ew', padx=5, pady=5)
202        self.toggle_level()
203
204    def set_modifiers_for_platform(self):
205        """Determine list of names of key modifiers for this platform.
206
207        The names are used to build Tk bindings -- it doesn't matter if the
208        keyboard has these keys; it matters if Tk understands them.  The
209        order is also important: key binding equality depends on it, so
210        config-keys.def must use the same ordering.
211        """
212        if sys.platform == "darwin":
213            self.modifiers = ['Shift', 'Control', 'Option', 'Command']
214        else:
215            self.modifiers = ['Control', 'Alt', 'Shift']
216        self.modifier_label = {'Control': 'Ctrl'}  # Short name.
217
218    def toggle_level(self):
219        "Toggle between basic and advanced keys."
220        if  self.button_level.cget('text').startswith('Advanced'):
221            self.clear_key_seq()
222            self.button_level.config(text='<< Basic Key Binding Entry')
223            self.frame_keyseq_advanced.lift()
224            self.frame_help_advanced.lift()
225            self.advanced_keys.focus_set()
226            self.advanced = True
227        else:
228            self.clear_key_seq()
229            self.button_level.config(text='Advanced Key Binding Entry >>')
230            self.frame_keyseq_basic.lift()
231            self.frame_controls_basic.lift()
232            self.advanced = False
233
234    def final_key_selected(self, event=None):
235        "Handler for clicking on key in basic settings list."
236        self.build_key_string()
237
238    def build_key_string(self):
239        "Create formatted string of modifiers plus the key."
240        keylist = modifiers = self.get_modifiers()
241        final_key = self.list_keys_final.get('anchor')
242        if final_key:
243            final_key = translate_key(final_key, modifiers)
244            keylist.append(final_key)
245        self.key_string.set(f"<{'-'.join(keylist)}>")
246
247    def get_modifiers(self):
248        "Return ordered list of modifiers that have been selected."
249        mod_list = [variable.get() for variable in self.modifier_vars]
250        return [mod for mod in mod_list if mod]
251
252    def clear_key_seq(self):
253        "Clear modifiers and keys selection."
254        self.list_keys_final.select_clear(0, 'end')
255        self.list_keys_final.yview('moveto', '0.0')
256        for variable in self.modifier_vars:
257            variable.set('')
258        self.key_string.set('')
259
260    def ok(self, event=None):
261        keys = self.key_string.get().strip()
262        if not keys:
263            self.showerror(title=self.keyerror_title, parent=self,
264                           message="No key specified.")
265            return
266        if (self.advanced or self.keys_ok(keys)) and self.bind_ok(keys):
267            self.result = keys
268        self.grab_release()
269        self.destroy()
270
271    def cancel(self, event=None):
272        self.result = ''
273        self.grab_release()
274        self.destroy()
275
276    def keys_ok(self, keys):
277        """Validity check on user's 'basic' keybinding selection.
278
279        Doesn't check the string produced by the advanced dialog because
280        'modifiers' isn't set.
281        """
282        final_key = self.list_keys_final.get('anchor')
283        modifiers = self.get_modifiers()
284        title = self.keyerror_title
285        key_sequences = [key for keylist in self.current_key_sequences
286                             for key in keylist]
287        if not keys.endswith('>'):
288            self.showerror(title, parent=self,
289                           message='Missing the final Key')
290        elif (not modifiers
291              and final_key not in FUNCTION_KEYS + MOVE_KEYS):
292            self.showerror(title=title, parent=self,
293                           message='No modifier key(s) specified.')
294        elif (modifiers == ['Shift']) \
295                 and (final_key not in
296                      FUNCTION_KEYS + MOVE_KEYS + ('Tab', 'Space')):
297            msg = 'The shift modifier by itself may not be used with'\
298                  ' this key symbol.'
299            self.showerror(title=title, parent=self, message=msg)
300        elif keys in key_sequences:
301            msg = 'This key combination is already in use.'
302            self.showerror(title=title, parent=self, message=msg)
303        else:
304            return True
305        return False
306
307    def bind_ok(self, keys):
308        "Return True if Tcl accepts the new keys else show message."
309        try:
310            binding = self.bind(keys, lambda: None)
311        except TclError as err:
312            self.showerror(
313                    title=self.keyerror_title, parent=self,
314                    message=(f'The entered key sequence is not accepted.\n\n'
315                             f'Error: {err}'))
316            return False
317        else:
318            self.unbind(keys, binding)
319            return True
320
321
322if __name__ == '__main__':
323    from unittest import main
324    main('idlelib.idle_test.test_config_key', verbosity=2, exit=False)
325
326    from idlelib.idle_test.htest import run
327    run(GetKeysDialog)
328