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