1""" 2Dialogs that query users and verify the answer before accepting. 3 4Query is the generic base class for a popup dialog. 5The user must either enter a valid answer or close the dialog. 6Entries are validated when <Return> is entered or [Ok] is clicked. 7Entries are ignored when [Cancel] or [X] are clicked. 8The 'return value' is .result set to either a valid answer or None. 9 10Subclass SectionName gets a name for a new config file section. 11Configdialog uses it for new highlight theme and keybinding set names. 12Subclass ModuleName gets a name for File => Open Module. 13Subclass HelpSource gets menu item and path for additions to Help menu. 14""" 15# Query and Section name result from splitting GetCfgSectionNameDialog 16# of configSectionNameDialog.py (temporarily config_sec.py) into 17# generic and specific parts. 3.6 only, July 2016. 18# ModuleName.entry_ok came from editor.EditorWindow.load_module. 19# HelpSource was extracted from configHelpSourceEdit.py (temporarily 20# config_help.py), with darwin code moved from ok to path_ok. 21 22import importlib.util, importlib.abc 23import os 24import shlex 25from sys import executable, platform # Platform is set for one test. 26 27from tkinter import Toplevel, StringVar, BooleanVar, W, E, S 28from tkinter.ttk import Frame, Button, Entry, Label, Checkbutton 29from tkinter import filedialog 30from tkinter.font import Font 31 32class Query(Toplevel): 33 """Base class for getting verified answer from a user. 34 35 For this base class, accept any non-blank string. 36 """ 37 def __init__(self, parent, title, message, *, text0='', used_names={}, 38 _htest=False, _utest=False): 39 """Create modal popup, return when destroyed. 40 41 Additional subclass init must be done before this unless 42 _utest=True is passed to suppress wait_window(). 43 44 title - string, title of popup dialog 45 message - string, informational message to display 46 text0 - initial value for entry 47 used_names - names already in use 48 _htest - bool, change box location when running htest 49 _utest - bool, leave window hidden and not modal 50 """ 51 self.parent = parent # Needed for Font call. 52 self.message = message 53 self.text0 = text0 54 self.used_names = used_names 55 56 Toplevel.__init__(self, parent) 57 self.withdraw() # Hide while configuring, especially geometry. 58 self.title(title) 59 self.transient(parent) 60 if not _utest: # Otherwise fail when directly run unittest. 61 self.grab_set() 62 63 windowingsystem = self.tk.call('tk', 'windowingsystem') 64 if windowingsystem == 'aqua': 65 try: 66 self.tk.call('::tk::unsupported::MacWindowStyle', 'style', 67 self._w, 'moveableModal', '') 68 except: 69 pass 70 self.bind("<Command-.>", self.cancel) 71 self.bind('<Key-Escape>', self.cancel) 72 self.protocol("WM_DELETE_WINDOW", self.cancel) 73 self.bind('<Key-Return>', self.ok) 74 self.bind("<KP_Enter>", self.ok) 75 76 self.create_widgets() 77 self.update_idletasks() # Need here for winfo_reqwidth below. 78 self.geometry( # Center dialog over parent (or below htest box). 79 "+%d+%d" % ( 80 parent.winfo_rootx() + 81 (parent.winfo_width()/2 - self.winfo_reqwidth()/2), 82 parent.winfo_rooty() + 83 ((parent.winfo_height()/2 - self.winfo_reqheight()/2) 84 if not _htest else 150) 85 ) ) 86 self.resizable(height=False, width=False) 87 88 if not _utest: 89 self.deiconify() # Unhide now that geometry set. 90 self.wait_window() 91 92 def create_widgets(self, ok_text='OK'): # Do not replace. 93 """Create entry (rows, extras, buttons. 94 95 Entry stuff on rows 0-2, spanning cols 0-2. 96 Buttons on row 99, cols 1, 2. 97 """ 98 # Bind to self the widgets needed for entry_ok or unittest. 99 self.frame = frame = Frame(self, padding=10) 100 frame.grid(column=0, row=0, sticky='news') 101 frame.grid_columnconfigure(0, weight=1) 102 103 entrylabel = Label(frame, anchor='w', justify='left', 104 text=self.message) 105 self.entryvar = StringVar(self, self.text0) 106 self.entry = Entry(frame, width=30, textvariable=self.entryvar) 107 self.entry.focus_set() 108 self.error_font = Font(name='TkCaptionFont', 109 exists=True, root=self.parent) 110 self.entry_error = Label(frame, text=' ', foreground='red', 111 font=self.error_font) 112 # Display or blank error by setting ['text'] =. 113 entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W) 114 self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E, 115 pady=[10,0]) 116 self.entry_error.grid(column=0, row=2, columnspan=3, padx=5, 117 sticky=W+E) 118 119 self.create_extra() 120 121 self.button_ok = Button( 122 frame, text=ok_text, default='active', command=self.ok) 123 self.button_cancel = Button( 124 frame, text='Cancel', command=self.cancel) 125 126 self.button_ok.grid(column=1, row=99, padx=5) 127 self.button_cancel.grid(column=2, row=99, padx=5) 128 129 def create_extra(self): pass # Override to add widgets. 130 131 def showerror(self, message, widget=None): 132 #self.bell(displayof=self) 133 (widget or self.entry_error)['text'] = 'ERROR: ' + message 134 135 def entry_ok(self): # Example: usually replace. 136 "Return non-blank entry or None." 137 entry = self.entry.get().strip() 138 if not entry: 139 self.showerror('blank line.') 140 return None 141 return entry 142 143 def ok(self, event=None): # Do not replace. 144 '''If entry is valid, bind it to 'result' and destroy tk widget. 145 146 Otherwise leave dialog open for user to correct entry or cancel. 147 ''' 148 self.entry_error['text'] = '' 149 entry = self.entry_ok() 150 if entry is not None: 151 self.result = entry 152 self.destroy() 153 else: 154 # [Ok] moves focus. (<Return> does not.) Move it back. 155 self.entry.focus_set() 156 157 def cancel(self, event=None): # Do not replace. 158 "Set dialog result to None and destroy tk widget." 159 self.result = None 160 self.destroy() 161 162 def destroy(self): 163 self.grab_release() 164 super().destroy() 165 166 167class SectionName(Query): 168 "Get a name for a config file section name." 169 # Used in ConfigDialog.GetNewKeysName, .GetNewThemeName (837) 170 171 def __init__(self, parent, title, message, used_names, 172 *, _htest=False, _utest=False): 173 super().__init__(parent, title, message, used_names=used_names, 174 _htest=_htest, _utest=_utest) 175 176 def entry_ok(self): 177 "Return sensible ConfigParser section name or None." 178 name = self.entry.get().strip() 179 if not name: 180 self.showerror('no name specified.') 181 return None 182 elif len(name)>30: 183 self.showerror('name is longer than 30 characters.') 184 return None 185 elif name in self.used_names: 186 self.showerror('name is already in use.') 187 return None 188 return name 189 190 191class ModuleName(Query): 192 "Get a module name for Open Module menu entry." 193 # Used in open_module (editor.EditorWindow until move to iobinding). 194 195 def __init__(self, parent, title, message, text0, 196 *, _htest=False, _utest=False): 197 super().__init__(parent, title, message, text0=text0, 198 _htest=_htest, _utest=_utest) 199 200 def entry_ok(self): 201 "Return entered module name as file path or None." 202 name = self.entry.get().strip() 203 if not name: 204 self.showerror('no name specified.') 205 return None 206 # XXX Ought to insert current file's directory in front of path. 207 try: 208 spec = importlib.util.find_spec(name) 209 except (ValueError, ImportError) as msg: 210 self.showerror(str(msg)) 211 return None 212 if spec is None: 213 self.showerror("module not found.") 214 return None 215 if not isinstance(spec.loader, importlib.abc.SourceLoader): 216 self.showerror("not a source-based module.") 217 return None 218 try: 219 file_path = spec.loader.get_filename(name) 220 except AttributeError: 221 self.showerror("loader does not support get_filename.") 222 return None 223 except ImportError: 224 # Some special modules require this (e.g. os.path) 225 try: 226 file_path = spec.loader.get_filename() 227 except TypeError: 228 self.showerror("loader failed to get filename.") 229 return None 230 return file_path 231 232 233class Goto(Query): 234 "Get a positive line number for editor Go To Line." 235 # Used in editor.EditorWindow.goto_line_event. 236 237 def entry_ok(self): 238 try: 239 lineno = int(self.entry.get()) 240 except ValueError: 241 self.showerror('not a base 10 integer.') 242 return None 243 if lineno <= 0: 244 self.showerror('not a positive integer.') 245 return None 246 return lineno 247 248 249class HelpSource(Query): 250 "Get menu name and help source for Help menu." 251 # Used in ConfigDialog.HelpListItemAdd/Edit, (941/9) 252 253 def __init__(self, parent, title, *, menuitem='', filepath='', 254 used_names={}, _htest=False, _utest=False): 255 """Get menu entry and url/local file for Additional Help. 256 257 User enters a name for the Help resource and a web url or file 258 name. The user can browse for the file. 259 """ 260 self.filepath = filepath 261 message = 'Name for item on Help menu:' 262 super().__init__( 263 parent, title, message, text0=menuitem, 264 used_names=used_names, _htest=_htest, _utest=_utest) 265 266 def create_extra(self): 267 "Add path widjets to rows 10-12." 268 frame = self.frame 269 pathlabel = Label(frame, anchor='w', justify='left', 270 text='Help File Path: Enter URL or browse for file') 271 self.pathvar = StringVar(self, self.filepath) 272 self.path = Entry(frame, textvariable=self.pathvar, width=40) 273 browse = Button(frame, text='Browse', width=8, 274 command=self.browse_file) 275 self.path_error = Label(frame, text=' ', foreground='red', 276 font=self.error_font) 277 278 pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0], 279 sticky=W) 280 self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E, 281 pady=[10,0]) 282 browse.grid(column=2, row=11, padx=5, sticky=W+S) 283 self.path_error.grid(column=0, row=12, columnspan=3, padx=5, 284 sticky=W+E) 285 286 def askfilename(self, filetypes, initdir, initfile): # htest # 287 # Extracted from browse_file so can mock for unittests. 288 # Cannot unittest as cannot simulate button clicks. 289 # Test by running htest, such as by running this file. 290 return filedialog.Open(parent=self, filetypes=filetypes)\ 291 .show(initialdir=initdir, initialfile=initfile) 292 293 def browse_file(self): 294 filetypes = [ 295 ("HTML Files", "*.htm *.html", "TEXT"), 296 ("PDF Files", "*.pdf", "TEXT"), 297 ("Windows Help Files", "*.chm"), 298 ("Text Files", "*.txt", "TEXT"), 299 ("All Files", "*")] 300 path = self.pathvar.get() 301 if path: 302 dir, base = os.path.split(path) 303 else: 304 base = None 305 if platform[:3] == 'win': 306 dir = os.path.join(os.path.dirname(executable), 'Doc') 307 if not os.path.isdir(dir): 308 dir = os.getcwd() 309 else: 310 dir = os.getcwd() 311 file = self.askfilename(filetypes, dir, base) 312 if file: 313 self.pathvar.set(file) 314 315 item_ok = SectionName.entry_ok # localize for test override 316 317 def path_ok(self): 318 "Simple validity check for menu file path" 319 path = self.path.get().strip() 320 if not path: #no path specified 321 self.showerror('no help file path specified.', self.path_error) 322 return None 323 elif not path.startswith(('www.', 'http')): 324 if path[:5] == 'file:': 325 path = path[5:] 326 if not os.path.exists(path): 327 self.showerror('help file path does not exist.', 328 self.path_error) 329 return None 330 if platform == 'darwin': # for Mac Safari 331 path = "file://" + path 332 return path 333 334 def entry_ok(self): 335 "Return apparently valid (name, path) or None" 336 self.path_error['text'] = '' 337 name = self.item_ok() 338 path = self.path_ok() 339 return None if name is None or path is None else (name, path) 340 341class CustomRun(Query): 342 """Get settings for custom run of module. 343 344 1. Command line arguments to extend sys.argv. 345 2. Whether to restart Shell or not. 346 """ 347 # Used in runscript.run_custom_event 348 349 def __init__(self, parent, title, *, cli_args=[], 350 _htest=False, _utest=False): 351 """cli_args is a list of strings. 352 353 The list is assigned to the default Entry StringVar. 354 The strings are displayed joined by ' ' for display. 355 """ 356 message = 'Command Line Arguments for sys.argv:' 357 super().__init__( 358 parent, title, message, text0=cli_args, 359 _htest=_htest, _utest=_utest) 360 361 def create_extra(self): 362 "Add run mode on rows 10-12." 363 frame = self.frame 364 self.restartvar = BooleanVar(self, value=True) 365 restart = Checkbutton(frame, variable=self.restartvar, onvalue=True, 366 offvalue=False, text='Restart shell') 367 self.args_error = Label(frame, text=' ', foreground='red', 368 font=self.error_font) 369 370 restart.grid(column=0, row=10, columnspan=3, padx=5, sticky='w') 371 self.args_error.grid(column=0, row=12, columnspan=3, padx=5, 372 sticky='we') 373 374 def cli_args_ok(self): 375 "Validity check and parsing for command line arguments." 376 cli_string = self.entry.get().strip() 377 try: 378 cli_args = shlex.split(cli_string, posix=True) 379 except ValueError as err: 380 self.showerror(str(err)) 381 return None 382 return cli_args 383 384 def entry_ok(self): 385 "Return apparently valid (cli_args, restart) or None." 386 cli_args = self.cli_args_ok() 387 restart = self.restartvar.get() 388 return None if cli_args is None else (cli_args, restart) 389 390 391if __name__ == '__main__': 392 from unittest import main 393 main('idlelib.idle_test.test_query', verbosity=2, exit=False) 394 395 from idlelib.idle_test.htest import run 396 run(Query, HelpSource, CustomRun) 397