Coverage for config_key.py: 21%
197 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-11 13:22 -0700
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
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)
24def translate_key(key, modifiers):
25 "Translate from keycap symbol to the Tkinter keysym."
26 mapping = {'Space':'space', 1b
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) 1b
39 if 'Shift' in modifiers and key in string.ascii_lowercase: 1b
40 key = key.upper() 1b
41 return f'Key-{key}' 1b
44class GetKeysFrame(Frame):
46 # Dialog title for invalid key sequence
47 keyerror_title = 'Key Sequence Error'
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()
76 def showerror(self, *args, **kwargs):
77 # Make testing easier. Replace in #30751.
78 messagebox.showerror(*args, **kwargs)
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')
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')
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)
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
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')
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)
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')
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')
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()
169 def set_modifiers_for_platform(self):
170 """Determine list of names of key modifiers for this platform.
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.
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
199 def final_key_selected(self, event=None):
200 "Handler for clicking on key in basic settings list."
201 self.build_key_string()
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)}>")
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]
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('')
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
236 def keys_ok(self, keys):
237 """Validity check on user's 'basic' keybinding selection.
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
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
282class GetKeysWindow(Toplevel):
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}")
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')
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()
330 @property
331 def result(self):
332 return self.frame.result
334 @result.setter
335 def result(self, value):
336 self.frame.result = value
338 def ok(self, event=None):
339 self.frame.ok()
340 self.grab_release()
341 self.destroy()
343 def cancel(self, event=None):
344 self.result = ''
345 self.grab_release()
346 self.destroy()
349if __name__ == '__main__': 349 ↛ 350line 349 didn't jump to line 350, because the condition on line 349 was never true
350 from unittest import main
351 main('idlelib.idle_test.test_config_key', verbosity=2, exit=False)
353 from idlelib.idle_test.htest import run
354 run(GetKeysDialog)