1"""
2Dialogs that query users and verify the answer before accepting.
3Use ttk widgets, limiting use to tcl/tk 8.5+, as in IDLE 3.6+.
4
5Query is the generic base class for a popup dialog.
6The user must either enter a valid answer or close the dialog.
7Entries are validated when <Return> is entered or [Ok] is clicked.
8Entries are ignored when [Cancel] or [X] are clicked.
9The 'return value' is .result set to either a valid answer or None.
10
11Subclass SectionName gets a name for a new config file section.
12Configdialog uses it for new highlight theme and keybinding set names.
13Subclass ModuleName gets a name for File => Open Module.
14Subclass HelpSource gets menu item and path for additions to Help menu.
15"""
16# Query and Section name result from splitting GetCfgSectionNameDialog
17# of configSectionNameDialog.py (temporarily config_sec.py) into
18# generic and specific parts.  3.6 only, July 2016.
19# ModuleName.entry_ok came from editor.EditorWindow.load_module.
20# HelpSource was extracted from configHelpSourceEdit.py (temporarily
21# config_help.py), with darwin code moved from ok to path_ok.
22
23import importlib
24import os
25from sys import executable, platform  # Platform is set for one test.
26
27from tkinter import Toplevel, StringVar, W, E, N, S
28from tkinter.ttk import Frame, Button, Entry, Label
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 popup, do not return until tk widget destroyed.
40
41        Additional subclass init must be done before calling this
42        unless  _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        Toplevel.__init__(self, parent)
52        self.withdraw()  # Hide while configuring, especially geometry.
53        self.parent = parent
54        self.title(title)
55        self.message = message
56        self.text0 = text0
57        self.used_names = used_names
58        self.transient(parent)
59        self.grab_set()
60        windowingsystem = self.tk.call('tk', 'windowingsystem')
61        if windowingsystem == 'aqua':
62            try:
63                self.tk.call('::tk::unsupported::MacWindowStyle', 'style',
64                             self._w, 'moveableModal', '')
65            except:
66                pass
67            self.bind("<Command-.>", self.cancel)
68        self.bind('<Key-Escape>', self.cancel)
69        self.protocol("WM_DELETE_WINDOW", self.cancel)
70        self.bind('<Key-Return>', self.ok)
71        self.bind("<KP_Enter>", self.ok)
72        self.resizable(height=False, width=False)
73        self.create_widgets()
74        self.update_idletasks()  # Needed here for winfo_reqwidth below.
75        self.geometry(  # Center dialog over parent (or below htest box).
76                "+%d+%d" % (
77                    parent.winfo_rootx() +
78                    (parent.winfo_width()/2 - self.winfo_reqwidth()/2),
79                    parent.winfo_rooty() +
80                    ((parent.winfo_height()/2 - self.winfo_reqheight()/2)
81                    if not _htest else 150)
82                ) )
83        if not _utest:
84            self.deiconify()  # Unhide now that geometry set.
85            self.wait_window()
86
87    def create_widgets(self):  # Call from override, if any.
88        # Bind to self widgets needed for entry_ok or unittest.
89        self.frame = frame = Frame(self, padding=10)
90        frame.grid(column=0, row=0, sticky='news')
91        frame.grid_columnconfigure(0, weight=1)
92
93        entrylabel = Label(frame, anchor='w', justify='left',
94                           text=self.message)
95        self.entryvar = StringVar(self, self.text0)
96        self.entry = Entry(frame, width=30, textvariable=self.entryvar)
97        self.entry.focus_set()
98        self.error_font = Font(name='TkCaptionFont',
99                               exists=True, root=self.parent)
100        self.entry_error = Label(frame, text=' ', foreground='red',
101                                 font=self.error_font)
102        self.button_ok = Button(
103                frame, text='OK', default='active', command=self.ok)
104        self.button_cancel = Button(
105                frame, text='Cancel', command=self.cancel)
106
107        entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W)
108        self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E,
109                        pady=[10,0])
110        self.entry_error.grid(column=0, row=2, columnspan=3, padx=5,
111                              sticky=W+E)
112        self.button_ok.grid(column=1, row=99, padx=5)
113        self.button_cancel.grid(column=2, row=99, padx=5)
114
115    def showerror(self, message, widget=None):
116        #self.bell(displayof=self)
117        (widget or self.entry_error)['text'] = 'ERROR: ' + message
118
119    def entry_ok(self):  # Example: usually replace.
120        "Return non-blank entry or None."
121        self.entry_error['text'] = ''
122        entry = self.entry.get().strip()
123        if not entry:
124            self.showerror('blank line.')
125            return None
126        return entry
127
128    def ok(self, event=None):  # Do not replace.
129        '''If entry is valid, bind it to 'result' and destroy tk widget.
130
131        Otherwise leave dialog open for user to correct entry or cancel.
132        '''
133        entry = self.entry_ok()
134        if entry is not None:
135            self.result = entry
136            self.destroy()
137        else:
138            # [Ok] moves focus.  (<Return> does not.)  Move it back.
139            self.entry.focus_set()
140
141    def cancel(self, event=None):  # Do not replace.
142        "Set dialog result to None and destroy tk widget."
143        self.result = None
144        self.destroy()
145
146
147class SectionName(Query):
148    "Get a name for a config file section name."
149    # Used in ConfigDialog.GetNewKeysName, .GetNewThemeName (837)
150
151    def __init__(self, parent, title, message, used_names,
152                 *, _htest=False, _utest=False):
153        super().__init__(parent, title, message, used_names=used_names,
154                         _htest=_htest, _utest=_utest)
155
156    def entry_ok(self):
157        "Return sensible ConfigParser section name or None."
158        self.entry_error['text'] = ''
159        name = self.entry.get().strip()
160        if not name:
161            self.showerror('no name specified.')
162            return None
163        elif len(name)>30:
164            self.showerror('name is longer than 30 characters.')
165            return None
166        elif name in self.used_names:
167            self.showerror('name is already in use.')
168            return None
169        return name
170
171
172class ModuleName(Query):
173    "Get a module name for Open Module menu entry."
174    # Used in open_module (editor.EditorWindow until move to iobinding).
175
176    def __init__(self, parent, title, message, text0,
177                 *, _htest=False, _utest=False):
178        super().__init__(parent, title, message, text0=text0,
179                       _htest=_htest, _utest=_utest)
180
181    def entry_ok(self):
182        "Return entered module name as file path or None."
183        self.entry_error['text'] = ''
184        name = self.entry.get().strip()
185        if not name:
186            self.showerror('no name specified.')
187            return None
188        # XXX Ought to insert current file's directory in front of path.
189        try:
190            spec = importlib.util.find_spec(name)
191        except (ValueError, ImportError) as msg:
192            self.showerror(str(msg))
193            return None
194        if spec is None:
195            self.showerror("module not found")
196            return None
197        if not isinstance(spec.loader, importlib.abc.SourceLoader):
198            self.showerror("not a source-based module")
199            return None
200        try:
201            file_path = spec.loader.get_filename(name)
202        except AttributeError:
203            self.showerror("loader does not support get_filename",
204                      parent=self)
205            return None
206        return file_path
207
208
209class HelpSource(Query):
210    "Get menu name and help source for Help menu."
211    # Used in ConfigDialog.HelpListItemAdd/Edit, (941/9)
212
213    def __init__(self, parent, title, *, menuitem='', filepath='',
214                 used_names={}, _htest=False, _utest=False):
215        """Get menu entry and url/local file for Additional Help.
216
217        User enters a name for the Help resource and a web url or file
218        name. The user can browse for the file.
219        """
220        self.filepath = filepath
221        message = 'Name for item on Help menu:'
222        super().__init__(
223                parent, title, message, text0=menuitem,
224                used_names=used_names, _htest=_htest, _utest=_utest)
225
226    def create_widgets(self):
227        super().create_widgets()
228        frame = self.frame
229        pathlabel = Label(frame, anchor='w', justify='left',
230                          text='Help File Path: Enter URL or browse for file')
231        self.pathvar = StringVar(self, self.filepath)
232        self.path = Entry(frame, textvariable=self.pathvar, width=40)
233        browse = Button(frame, text='Browse', width=8,
234                        command=self.browse_file)
235        self.path_error = Label(frame, text=' ', foreground='red',
236                                font=self.error_font)
237
238        pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0],
239                       sticky=W)
240        self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E,
241                       pady=[10,0])
242        browse.grid(column=2, row=11, padx=5, sticky=W+S)
243        self.path_error.grid(column=0, row=12, columnspan=3, padx=5,
244                             sticky=W+E)
245
246    def askfilename(self, filetypes, initdir, initfile):  # htest #
247        # Extracted from browse_file so can mock for unittests.
248        # Cannot unittest as cannot simulate button clicks.
249        # Test by running htest, such as by running this file.
250        return filedialog.Open(parent=self, filetypes=filetypes)\
251               .show(initialdir=initdir, initialfile=initfile)
252
253    def browse_file(self):
254        filetypes = [
255            ("HTML Files", "*.htm *.html", "TEXT"),
256            ("PDF Files", "*.pdf", "TEXT"),
257            ("Windows Help Files", "*.chm"),
258            ("Text Files", "*.txt", "TEXT"),
259            ("All Files", "*")]
260        path = self.pathvar.get()
261        if path:
262            dir, base = os.path.split(path)
263        else:
264            base = None
265            if platform[:3] == 'win':
266                dir = os.path.join(os.path.dirname(executable), 'Doc')
267                if not os.path.isdir(dir):
268                    dir = os.getcwd()
269            else:
270                dir = os.getcwd()
271        file = self.askfilename(filetypes, dir, base)
272        if file:
273            self.pathvar.set(file)
274
275    item_ok = SectionName.entry_ok  # localize for test override
276
277    def path_ok(self):
278        "Simple validity check for menu file path"
279        path = self.path.get().strip()
280        if not path: #no path specified
281            self.showerror('no help file path specified.', self.path_error)
282            return None
283        elif not path.startswith(('www.', 'http')):
284            if path[:5] == 'file:':
285                path = path[5:]
286            if not os.path.exists(path):
287                self.showerror('help file path does not exist.',
288                               self.path_error)
289                return None
290            if platform == 'darwin':  # for Mac Safari
291                path =  "file://" + path
292        return path
293
294    def entry_ok(self):
295        "Return apparently valid (name, path) or None"
296        self.entry_error['text'] = ''
297        self.path_error['text'] = ''
298        name = self.item_ok()
299        path = self.path_ok()
300        return None if name is None or path is None else (name, path)
301
302
303if __name__ == '__main__':
304    import unittest
305    unittest.main('idlelib.idle_test.test_query', verbosity=2, exit=False)
306
307    from idlelib.idle_test.htest import run
308    run(Query, HelpSource)
309