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