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