import unittest
import tkinter
-from tkinter import messagebox
+from tkinter import messagebox, ttk
from test.support import requires, swap_attr
from test.test_tkinter.support import setUpModule # noqa: F401
from test.test_tkinter.support import AbstractDefaultRootTest, AbstractTkTest
from tkinter.simpledialog import (Dialog, SimpleDialog,
askinteger, askfloat, askstring,
- _QueryInteger, _QueryFloat, _QueryString)
+ _QueryInteger, _QueryFloat, _QueryString,
+ _underline_ampersand, _find_alt_key_target)
requires('gui')
self.addCleanup(lambda: d.root.winfo_exists() and d.root.destroy())
return d
- def test_message(self):
- # The text is shown in a message widget.
- d = self.create(text='Hello?')
- self.assertEqual(d.message.winfo_class(), 'Message')
- self.assertEqual(str(d.message.cget('text')), 'Hello?')
+ # --- Widget set and appearance ---
+
+ def test_use_ttk(self):
+ # By default SimpleDialog uses the themed (ttk) widgets (tk::MessageBox).
+ d = self.create(buttons=['OK'], bitmap='warning')
+ self.assertEqual(d._buttons[0].winfo_class(), 'TButton')
+ self.assertEqual(d.message.winfo_class(), 'TLabel')
+ self.assertEqual(str(d.message.cget('anchor')), 'nw') # cf. MessageBox
+ # The standard icons are drawn with themed images (cf. MessageBox).
+ self.assertEqual(d.bitmap.winfo_class(), 'TLabel')
+ self.assertIn('::tk::icons::warning', str(d.bitmap.cget('image')))
+ # tk::MessageBox makes the buttons equal width.
+ self.assertEqual(
+ str(d.root.children['bot'].grid_columnconfigure(0)['uniform']),
+ 'buttons')
+ # The dialog uses the themed background colour (cf. MessageBox).
+ self.assertEqual(str(d.root.cget('background')),
+ ttk.Style(d.root).lookup('.', 'background'))
+ # The bindings work with the themed buttons too.
+ d._buttons[0].focus_force()
+ d.root.update()
+ d.root.event_generate('<Return>')
+ d.root.update()
+ self.assertEqual(d.num, 0)
+
+ def test_use_classic(self):
+ # use_ttk=False uses the classic Tk widgets, modelled on tk_dialog.
+ d = self.create(buttons=['OK'], bitmap='warning', use_ttk=False)
+ self.assertEqual(d._buttons[0].winfo_class(), 'Button')
+ self.assertEqual(d.message.winfo_class(), 'Label')
+ if d.root._windowingsystem == 'x11':
+ self.assertEqual(str(d.frame.cget('relief')), 'raised')
+ # tk_dialog does not make the buttons equal width.
+ self.assertIsNone(d.root.children['bot'].grid_columnconfigure(0)['uniform'])
+ # The bitmap is a classic monochrome label.
+ self.assertEqual(str(d.bitmap.cget('bitmap')), 'warning')
def test_class_name(self):
- # class_ sets the Tk class of the dialog window.
+ # class_ sets the Tk class of the dialog window (default 'Dialog').
+ self.assertEqual(self.create().root.winfo_class(), 'Dialog')
d = self.create(class_='MyDialog')
self.assertEqual(d.root.winfo_class(), 'MyDialog')
- def test_button(self):
- # Pressing a button records its index.
+ # --- Message, detail and bitmap content ---
+
+ def test_no_detail(self):
+ # Without a detail message the message label expands.
+ d = self.create()
+ self.assertIsNone(d.detail)
+ self.assertEqual(int(d.message.pack_info()['expand']), 1)
+
+ def test_detail(self):
+ # The detail message is shown below the main message.
+ d = self.create(detail='More information.')
+ self.assertEqual(d.detail.winfo_class(), 'TLabel')
+ self.assertEqual(str(d.detail.cget('text')), 'More information.')
+ self.assertEqual(str(d.detail.cget('anchor')), 'nw') # cf. MessageBox
+ # With a detail message it expands and the main message does not.
+ self.assertEqual(int(d.message.pack_info()['expand']), 0)
+ self.assertEqual(int(d.detail.pack_info()['expand']), 1)
+
+ def test_bitmap_fallback(self):
+ # A non-standard bitmap has no themed image, so even the ttk version
+ # falls back to a classic bitmap label.
+ d = self.create(buttons=['OK'], bitmap='questhead')
+ self.assertEqual(d.bitmap.winfo_class(), 'Label')
+ self.assertEqual(str(d.bitmap.cget('bitmap')), 'questhead')
+
+ def test_bitmap_detail_layout(self):
+ # The bitmap is packed first to claim the whole left side; the message
+ # and detail stack on its right, as in tk::MessageBox.
+ d = self.create(buttons=['OK'], bitmap='warning', detail='More.')
+ top = d.root.children['top']
+ layout = [(w.winfo_name(), str(w.pack_info()['side']))
+ for w in top.pack_slaves()]
+ self.assertEqual(layout,
+ [('bitmap', 'left'), ('msg', 'top'), ('dtl', 'top')])
+
+ # --- Buttons and keyboard ---
+
+ def test_button_options(self):
+ # A button entry can be a mapping of options, not just a label (like
+ # the "[name ?-option value ...?]" button specs in tk::MessageBox).
+ d = self.create(buttons=['Yes',
+ {'text': 'No', 'underline': 0, 'width': 12}])
+ yes, no = d._buttons
+ self.assertEqual(str(yes.cget('text')), 'Yes')
+ self.assertEqual(str(no.cget('text')), 'No')
+ self.assertEqual(int(no.cget('underline')), 0)
+ self.assertEqual(str(no.cget('width')), '12')
+ # The dialog still controls the default ring (default=0) ...
+ self.assertEqual(str(yes.cget('default')), 'active')
+ self.assertEqual(str(no.cget('default')), 'normal')
+ # ... and the command, which records the button index.
+ no.invoke()
+ self.assertEqual(d.num, 1)
+
+ def test_default_ring(self):
+ # The default ring follows the keyboard focus among the buttons
+ # (cf. tk::MessageBox).
+ d = self.create() # buttons ['Yes', 'No'], default 0
+ b0, b1 = d._buttons
+ self.assertEqual(str(b1.cget('default')), 'normal')
+ b1.focus_force()
+ d.root.update()
+ self.assertEqual(str(b1.cget('default')), 'active') # focused -> ring
+ b0.focus_force()
+ d.root.update()
+ self.assertEqual(str(b1.cget('default')), 'normal') # unfocused -> none
+
+ def test_alt_key(self):
+ # Alt + an underlined character (the "underline" button option) invokes
+ # the matching button (cf. tk::AmpWidget in tk::MessageBox).
+ d = self.create(buttons=['Yes', {'text': 'No', 'underline': 0}])
+ d._buttons[0].focus_force()
+ d.root.update()
+ d.root.event_generate('<Alt-n>') # "No" -> underline 0 -> "N"
+ d.root.update()
+ self.assertEqual(d.num, 1)
+
+ def test_return_invokes_focused_button(self):
+ # <Return> invokes the button with the focus, even if it is not the
+ # default and the focus was not moved by keyboard traversal.
+ d = self.create(buttons=['Yes', 'No']) # default 0
+ d._buttons[1].focus_force()
+ d.root.update()
+ d.root.event_generate('<Return>')
+ d.root.update()
+ self.assertEqual(d.num, 1)
+
+ def test_focus_next_then_return(self):
+ # <Tab> moves the focus to the next button; <Return> invokes it.
d = self.create(buttons=['Yes', 'No'])
- d.frame.winfo_children()[1].invoke() # "No"
+ d._buttons[0].focus_force()
+ d.root.update()
+ d._buttons[0].event_generate('<Tab>')
+ d.root.update()
+ d.root.event_generate('<Return>')
+ d.root.update()
self.assertEqual(d.num, 1)
- def test_default_button(self):
- # The default button is drawn with a raised border.
- d = self.create(buttons=['Yes', 'No'], default=0)
- self.assertEqual(str(d.frame.winfo_children()[0].cget('relief')), 'ridge')
+ def test_focus_prev_then_return(self):
+ # <Shift-Tab> moves the focus to the previous button.
+ d = self.create(buttons=['Yes', 'No'])
+ d._buttons[1].focus_force()
+ d.root.update()
+ d._buttons[1].event_generate('<Shift-Tab>')
+ d.root.update()
+ d.root.event_generate('<Return>')
+ d.root.update()
+ self.assertEqual(d.num, 0)
def test_return_activates_default(self):
- # <Return> invokes the default button.
+ # <Return> with the focus off the buttons invokes the default button.
d = self.create() # default 0
- d.root.focus_force()
+ d.root.focus_force() # the dialog, not a button, has the focus
d.root.update()
d.root.event_generate('<Return>')
d.root.update()
self.assertEqual(d.num, 0)
def test_return_no_default(self):
- # With no default button, <Return> rings the bell and leaves the dialog
- # open instead of activating a button.
+ # With no default button, <Return> off the buttons rings the bell and
+ # leaves the dialog open instead of activating a button.
d = self.create(default=None)
- d.root.focus_force()
+ d.root.focus_force() # the dialog, not a button, has the focus
d.root.update()
bells = []
with swap_attr(d.root, 'bell', lambda *a, **k: bells.append(True)):
self.assertIsNone(d.num)
self.assertTrue(d.root.winfo_exists())
+ # --- Modal lifecycle ---
+
+ def test_destroy_cancels(self):
+ # Destroying the window records the cancel index.
+ d = self.create()
+ d.root.update()
+ d.root.destroy()
+ self.assertEqual(d.num, 1)
+
def test_wm_delete_cancels(self):
# Closing the window through the window manager records the cancel index.
d = self.create() # cancel 1
def test_go(self):
# go() runs the modal loop and returns the chosen button's index.
d = self.create()
- d.root.after(1, lambda: d.done(0))
+ d.root.after(1, lambda: d._buttons[0].invoke())
self.assertEqual(d.go(), 0)
class DialogTest(AbstractTkTest, unittest.TestCase):
- # Dialog is a base class for custom dialogs; exercise it via _QueryInteger.
+ # Dialog's button box is modelled on tk::MessageBox.
def open(self, **kw):
with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)):
self.addCleanup(lambda: d.winfo_exists() and d.destroy())
return d
- def buttons(self, d):
- # Map the button box's buttons by their label.
- return {str(b.cget('text')): b
- for frame in d.winfo_children()
- for b in frame.winfo_children()
- if b.winfo_class() == 'Button'}
+ # --- Widget set and appearance ---
+
+ def test_use_ttk(self):
+ # The query dialogs use the themed (ttk) widgets by default.
+ d = self.open()
+ self.assertEqual(d.children['ok'].winfo_class(), 'TButton')
+ self.assertEqual(d.entry.winfo_class(), 'TEntry')
+ # tk::MessageBox makes the buttons equal width.
+ self.assertEqual(
+ str(d.children['bot'].grid_columnconfigure(0)['uniform']), 'buttons')
+
+ def test_use_classic(self):
+ # use_ttk=False uses the classic Tk widgets, modelled on tk_dialog.
+ d = self.open(use_ttk=False)
+ self.assertEqual(d.children['ok'].winfo_class(), 'Button')
+ self.assertEqual(d.entry.winfo_class(), 'Entry')
+ if d._windowingsystem == 'x11':
+ self.assertEqual(str(d.children['bot'].cget('relief')), 'raised')
+ # tk_dialog does not make the buttons equal width.
+ self.assertIsNone(d.children['bot'].grid_columnconfigure(0)['uniform'])
+ # The bindings work with the classic buttons too.
+ invoked = []
+ cancel = d.children['cancel']
+ cancel.configure(command=lambda: invoked.append(True))
+ cancel.focus_force()
+ d.update()
+ d.event_generate('<Return>')
+ d.update()
+ self.assertTrue(invoked)
def test_background(self):
- # The classic dialog keeps the default Toplevel background.
+ # The ttk dialog adopts the ttk background, even a customized one,
+ # while the classic dialog keeps the default Toplevel background.
+ style = ttk.Style(self.root)
+ old = style.lookup('.', 'background')
+ style.configure('.', background='#123456')
+ self.addCleanup(style.configure, '.', background=old)
d = self.open()
+ self.assertEqual(str(d.cget('background')), '#123456')
+ d = self.open(use_ttk=False)
ref = tkinter.Toplevel(self.root)
self.addCleanup(ref.destroy)
self.assertEqual(str(d.cget('background')), str(ref.cget('background')))
- def test_buttons(self):
- # The button box has OK (the default) and Cancel buttons.
- buttons = self.buttons(self.open())
- self.assertEqual(set(buttons), {'OK', 'Cancel'})
- self.assertEqual(str(buttons['OK'].cget('default')), 'active')
+ def test_base_classic_by_default(self):
+ # The Dialog base defaults to classic widgets so that subclasses adding
+ # classic widgets keep their look; only the query dialogs opt into ttk.
+ class MyDialog(Dialog):
+ def body(self, master):
+ pass
+ with swap_attr(Dialog, 'wait_window', staticmethod(lambda w: None)):
+ d = MyDialog(self.root, 'Title')
+ self.addCleanup(lambda: d.winfo_exists() and d.destroy())
+ self.assertEqual(d.children['ok'].winfo_class(), 'Button')
+
+ # --- Buttons and keyboard ---
- def test_ok(self):
- # The OK button validates the entry and stores the result.
+ def test_button_default(self):
d = self.open()
- d.entry.insert(0, '42')
- self.buttons(d)['OK'].invoke()
- self.assertEqual(d.result, 42)
- self.assertFalse(d.winfo_exists()) # The dialog closed.
+ self.assertEqual(str(d.children['ok'].cget('default')), 'active')
+ self.assertEqual(str(d.children['cancel'].cget('default')), 'normal')
+
+ def test_underline_ampersand(self):
+ self.assertEqual(_underline_ampersand('Yes'), ('Yes', -1))
+ self.assertEqual(_underline_ampersand('&Yes'), ('Yes', 0))
+ self.assertEqual(_underline_ampersand('Save &As'), ('Save As', 5))
+ self.assertEqual(_underline_ampersand('A&&B'), ('A&B', -1))
+ self.assertEqual(_underline_ampersand('&a&b'), ('ab', 0))
+
+ def test_button_accelerator(self):
+ # The buttons' "&" accelerators are parsed (cf. tk::AmpWidget).
+ d = self.open()
+ ok = d.children['ok'] # "&OK" -> underline 0 -> "O"
+ self.assertEqual(str(ok.cget('text')), 'OK')
+ self.assertEqual(int(ok.cget('underline')), 0)
- def test_cancel(self):
- # The Cancel button closes the dialog without a result.
+ def test_default_ring(self):
+ # The default ring follows the keyboard focus among the buttons.
d = self.open()
- self.buttons(d)['Cancel'].invoke()
- self.assertIsNone(d.result)
- self.assertFalse(d.winfo_exists())
+ cancel = d.children['cancel']
+ self.assertEqual(str(cancel.cget('default')), 'normal')
+ cancel.focus_force()
+ d.update()
+ self.assertEqual(str(cancel.cget('default')), 'active')
+ d.children['ok'].focus_force()
+ d.update()
+ self.assertEqual(str(cancel.cget('default')), 'normal')
+
+ def test_find_alt_key_target(self):
+ d = self.open()
+ ok = d.children['ok'] # "&OK" -> "O"
+ cancel = d.children['cancel'] # "&Cancel" -> "C"
+ self.assertIs(_find_alt_key_target(d, 'o'), ok)
+ self.assertIs(_find_alt_key_target(d, 'O'), ok) # case-insensitive
+ self.assertIs(_find_alt_key_target(d, 'c'), cancel)
+ self.assertIsNone(_find_alt_key_target(d, 'q'))
+
+ def test_alt_key(self):
+ # The accelerator key (Alt + the underlined letter) invokes the button:
+ # <Alt-Key> -> _alt_key -> the button's <<AltUnderlined>> -> invoke.
+ d = self.open()
+ invoked = []
+ cancel = d.children['cancel'] # "&Cancel"
+ cancel.configure(command=lambda: invoked.append(True))
+ d.focus_force()
+ d.update()
+ d.event_generate('<Alt-c>')
+ d.update()
+ self.assertTrue(invoked)
+
+ def test_return_invokes_focused_button(self):
+ # <Return> invokes the focused button.
+ d = self.open()
+ invoked = []
+ cancel = d.children['cancel']
+ cancel.configure(command=lambda: invoked.append(True))
+ cancel.focus_force()
+ d.update()
+ d.event_generate('<Return>')
+ d.update()
+ self.assertEqual(invoked, [True])
+
+ def test_focus_next_then_return(self):
+ # <Tab> moves the focus to the next button; <Return> invokes it.
+ d = self.open()
+ invoked = []
+ for name in ('ok', 'cancel'):
+ d.children[name].configure(command=lambda name=name: invoked.append(name))
+ ok = d.children['ok']
+ ok.focus_force()
+ d.update()
+ ok.event_generate('<Tab>') # OK -> Cancel
+ d.update()
+ d.event_generate('<Return>')
+ d.update()
+ self.assertEqual(invoked, ['cancel'])
+
+ def test_focus_prev_then_return(self):
+ # <Shift-Tab> moves the focus to the previous button.
+ d = self.open()
+ invoked = []
+ for name in ('ok', 'cancel'):
+ d.children[name].configure(command=lambda name=name: invoked.append(name))
+ cancel = d.children['cancel']
+ cancel.focus_force()
+ d.update()
+ cancel.event_generate('<Shift-Tab>') # Cancel -> OK
+ d.update()
+ d.event_generate('<Return>')
+ d.update()
+ self.assertEqual(invoked, ['ok'])
class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase):
d.event_generate(key)
d.update()
+ # --- Prompt and entry ---
+
+ def test_prompt_wraplength(self):
+ # A long prompt wraps instead of widening the dialog (cf. MessageBox).
+ d = self.open(_QueryInteger)
+ body = d.children['top']
+ label = [w for w in body.winfo_children() if w is not d.entry][0]
+ self.assertEqual(str(label.cget('wraplength')), '3i')
+
+ def test_prompt_column_expands(self):
+ # The prompt and entry column expands to the full width, like the
+ # weighted message column in tk::MessageBox.
+ d = self.open(_QueryInteger)
+ body = d.children['top']
+ self.assertEqual(body.grid_columnconfigure(0)['weight'], 1)
+
def test_initialvalue(self):
# The entry is pre-filled with the initial value, which is accepted.
d = self.open(_QueryInteger, initialvalue=42)
d = self.open(_QueryString, show='*')
self.assertEqual(str(d.entry.cget('show')), '*')
+ # --- Accept and cancel ---
+
def test_return_accepts(self):
for query, value, expected in [
(_QueryInteger, '42', 42),
self.assertIsNone(d.result)
self.assertFalse(d.winfo_exists())
+ # --- Validation ---
+
def test_invalid_value(self):
warnings = []
d = self.open(_QueryInteger)
self.assertEqual(d.result, 20)
self.assertFalse(d.winfo_exists())
+ # --- Convenience ask* functions ---
+
def run_ask(self, ask, value, **kw):
# Drive a modal ask* function: enter a value and accept it.
def accept(d):
askstring -- get a string from the user
"""
-from tkinter import Button, Entry, Frame, Label, Message, Tk, Toplevel
+import tkinter
+from tkinter import Button, Label, Tk, Toplevel, TclError
from tkinter import _get_temp_root, _destroy_temp_root
from tkinter import messagebox
-from tkinter.constants import ACTIVE, BOTH, END, LEFT, RIDGE, W, E
+from tkinter import ttk
+from tkinter.constants import *
+import contextlib
__all__ = ["SimpleDialog", "Dialog", "askinteger", "askfloat", "askstring"]
+# The standard dialog icons, which tk::MessageBox draws with themed images
+# instead of the classic monochrome bitmaps.
+_ICON_IMAGES = {
+ 'error': '::tk::icons::error',
+ 'info': '::tk::icons::information',
+ 'question': '::tk::icons::question',
+ 'warning': '::tk::icons::warning',
+}
+
+
+# Based on the Tk ::tk_dialog procedure, themed like ::tk::MessageBox by
+# default.
class SimpleDialog:
+ def _widget(self, klass, master, **kw):
+ # Create a themed (ttk) or classic (tkinter) widget.
+ return getattr(ttk if self.use_ttk else tkinter, klass)(master, **kw)
+
def __init__(self, master,
text='', buttons=[], default=None, cancel=None,
- title=None, class_=None):
- if class_:
- self.root = Toplevel(master, class_=class_)
- else:
- self.root = Toplevel(master)
- if title:
- self.root.title(title)
- self.root.iconname(title)
-
+ title=None, class_=None, *, bitmap=None, detail='',
+ use_ttk=True):
+ # Use the themed (ttk) widgets (modelled on tk::MessageBox) by default,
+ # or the classic Tk widgets (tk_dialog) if use_ttk is false.
+ self.use_ttk = use_ttk
+
+ # 1. Create the top-level window and divide it into top
+ # and bottom parts.
+
+ class_ = class_ or 'Dialog'
+ self.root = Toplevel(master, class_=class_)
+ # The default value of the title is space (" ") not the empty string
+ # because for some window managers, a
+ # w.title("")
+ # causes the window title to be w._name instead of the empty string.
+ self.root.title(title or ' ')
+ self.root.iconname(class_)
+ toplevel = master.winfo_toplevel()
+ if toplevel.winfo_viewable():
+ self.root.wm_transient(toplevel)
_setup_dialog(self.root)
-
- self.message = Message(self.root, text=text, aspect=400)
- self.message.pack(expand=1, fill=BOTH)
- self.frame = Frame(self.root)
- self.frame.pack()
+ if self.use_ttk:
+ self.root.configure(
+ background=ttk.Style(self.root).lookup('.', 'background'))
+
+ bot = self._widget('Frame', self.root, name='bot')
+ top = self._widget('Frame', self.root, name='top')
+ # The classic dialog (tk_dialog) gives its frames a raised border on
+ # X11; the themed one (tk::MessageBox) does not.
+ if not self.use_ttk and self.root._windowingsystem == 'x11':
+ bot.configure(relief=RAISED, bd=1)
+ top.configure(relief=RAISED, bd=1)
+ bot.pack(side=BOTTOM, fill=BOTH)
+ top.pack(side=TOP, fill=BOTH, expand=1)
+ bot.grid_anchor(CENTER)
+
+ # 2. Fill the top part with bitmap and message (use the option
+ # database for -wraplength and -font so that they can be
+ # overridden by the caller).
+
+ master.option_add(f'*{class_}.msg.wrapLength', '3i', 'widgetDefault')
+ master.option_add(f'*{class_}.msg.font', 'TkCaptionFont', 'widgetDefault')
+ master.option_add(f'*{class_}.dtl.wrapLength', '3i', 'widgetDefault')
+ master.option_add(f'*{class_}.dtl.font', 'TkDefaultFont', 'widgetDefault')
+
+ # tk::MessageBox and tk_dialog pad the top part differently.
+ pad = '2m' if self.use_ttk else '3m'
+
+ # The bitmap is packed first to claim the whole left side; the message
+ # and detail stack on its right, as in tk::MessageBox.
+ if bitmap:
+ if self.root._windowingsystem == 'aqua' and bitmap == 'error':
+ bitmap = 'stop'
+ image = _ICON_IMAGES.get(bitmap) if self.use_ttk else None
+ if image is not None and self.root.winfo_depth() >= 4:
+ # tk::MessageBox draws the standard icons with themed images.
+ self.bitmap = ttk.Label(self.root, name='bitmap', image=image)
+ else:
+ # ttk.Label has no -bitmap option, so use a classic label.
+ self.bitmap = Label(self.root, name='bitmap', bitmap=bitmap)
+ # The themed dialog anchors the icon to the top (like the bitmap's
+ # "nw" sticky in tk::MessageBox); the classic one centers it.
+ anchor = N if self.use_ttk else CENTER
+ self.bitmap.pack(in_=top, side=LEFT, anchor=anchor,
+ padx=pad, pady=pad)
+
+ self.message = self._widget('Label', self.root, name='msg',
+ justify=LEFT, text=text)
+ self.detail = None
+ if self.use_ttk:
+ # tk::MessageBox anchors the message to the top-left corner.
+ self.message.configure(anchor=NW)
+ # The message expands to fill the space, unless there is a detail
+ # message below it which takes the extra space instead (cf.
+ # tk::MessageBox).
+ self.message.pack(in_=top, side=TOP, expand=not detail, fill=BOTH,
+ padx=pad, pady=pad)
+ if detail:
+ self.detail = self._widget('Label', self.root, name='dtl',
+ justify=LEFT, text=detail)
+ if self.use_ttk:
+ self.detail.configure(anchor=NW)
+ self.detail.pack(in_=top, side=TOP, expand=1, fill=BOTH,
+ padx=pad, pady=(0, pad))
+
+ self.frame = bot
self.num = default
self.cancel = cancel
self.default = default
- self.root.bind('<Return>', self.return_event)
- for num in range(len(buttons)):
- s = buttons[num]
- b = Button(self.frame, text=s,
- command=(lambda self=self, num=num: self.done(num)))
- if num == default:
- b.config(relief=RIDGE, borderwidth=8)
- b.pack(side=LEFT, fill=BOTH, expand=1)
+
+ # 3. Create a row of buttons at the bottom of the dialog. Each entry
+ # of "buttons" is either a label, or a mapping of button options -- like
+ # the "[name ?-option value ...?]" button specs in tk::MessageBox.
+
+ # tk::MessageBox and tk_dialog space the buttons differently.
+ padx, pady = ('3m', '2m') if self.use_ttk else ('7.5p', '3p')
+ self._buttons = []
+ for i, but in enumerate(buttons):
+ opts = {'text': but} if isinstance(but, str) else dict(but)
+ b = self._widget('Button', self.root, name=f'button{i}', **opts)
+ # The dialog controls the command and the default ring, overriding
+ # anything set in the button options (cf. tk::MessageBox).
+ b.configure(command=(lambda self=self, i=i: self.done(i)),
+ default=ACTIVE if i == default else NORMAL)
+ # Alt + the underlined character (an "underline" button option)
+ # invokes the button (cf. tk::AmpWidget in tk::MessageBox).
+ b.bind('<<AltUnderlined>>', lambda e: e.widget.invoke())
+ b.grid(in_=bot, column=i, row=0, sticky=EW, padx=padx, pady=pady)
+ # tk::MessageBox makes the buttons equal width; tk_dialog does not.
+ bot.grid_columnconfigure(i, uniform='buttons' if self.use_ttk else '')
+ # We boost the size of some Mac buttons for l&f
+ if self.root._windowingsystem == 'aqua':
+ if str(opts.get('text', '')).lower() in ('ok', 'cancel'):
+ bot.grid_columnconfigure(i, minsize=90)
+ b.grid_configure(pady=7)
+ self._buttons.append(b)
+
+ # 4. Bind <Return> to invoke the focused button, or the default button
+ # if the focus is elsewhere. Unlike tk_dialog (which tracks the focus
+ # by rebinding <Return> on <<PrevWindow>>/<<NextWindow>>), this reads
+ # the live focus, like tk::MessageBox, so it also works with the mouse.
+
+ def on_return(event):
+ if event.widget.winfo_class() in ('Button', 'TButton'):
+ event.widget.invoke()
+ else:
+ self.return_event(event)
+ self.root.bind('<Return>', on_return)
+ # Alt + an underlined character invokes the matching button (cf.
+ # ::tk::AltKeyInDialog, bound by tk::MessageBox).
+ self.root.bind('<Alt-Key>', self._alt_key)
+ # The default ring follows the keyboard focus among the buttons
+ # (cf. tk::MessageBox).
+ self.root.bind('<FocusIn>', lambda e: self._set_default(e.widget, ACTIVE))
+ self.root.bind('<FocusOut>', lambda e: self._set_default(e.widget, NORMAL))
+
+ # 5. Bind <Destroy> to record the cancel index, in case the window is
+ # destroyed by something else (e.g. its parent being destroyed).
+
+ def on_destroy(event):
+ self.num = cancel
+ self.root.quit()
+ self.root.bind('<Destroy>', on_destroy)
+
self.root.protocol('WM_DELETE_WINDOW', self.wm_delete_window)
- self.root.transient(master)
_place_window(self.root, master)
def go(self):
self.root.wait_visibility()
- self.root.grab_set()
- self.root.mainloop()
- self.root.destroy()
+ if self.default is not None:
+ focus = self._buttons[self.default]
+ else:
+ focus = self.root
+
+ with _temp_grab_focus(self.root, focus):
+ try:
+ self.root.mainloop()
+ finally:
+ try:
+ # It's possible that the window has already been destroyed,
+ # hence this "try/except". Delete the Destroy handler so that
+ # self.num doesn't get reset by it.
+ self.root.bind('<Destroy>', '')
+ except TclError:
+ pass
return self.num
def return_event(self, event):
self.num = num
self.root.quit()
+ def _alt_key(self, event):
+ # Invoke the button whose accelerator matches the Alt key.
+ target = _find_alt_key_target(self.root, event.char)
+ if target is not None:
+ target.event_generate('<<AltUnderlined>>')
+
+ def _set_default(self, widget, state):
+ # Set a button's default ring.
+ if widget.winfo_class() in ('Button', 'TButton'):
+ widget.configure(default=state)
+
+# A base class for custom dialogs, with a button box modelled on
+# ::tk::MessageBox.
class Dialog(Toplevel):
'''Class to open dialogs.
This class is intended as a base class for custom dialogs
'''
- def __init__(self, parent, title = None):
+ def _widget(self, klass, master, **kw):
+ # Create a themed (ttk) or classic (tkinter) widget.
+ return getattr(ttk if self.use_ttk else tkinter, klass)(master, **kw)
+
+ def _frame(self, name):
+ # The classic dialog (tk_dialog) gives its frames a raised border on
+ # X11; the themed one (tk::MessageBox) does not.
+ frame = self._widget('Frame', self, name=name)
+ if not self.use_ttk and self._windowingsystem == 'x11':
+ frame.configure(relief=RAISED, bd=1)
+ return frame
+
+ def __init__(self, parent, title=None, *, use_ttk=False):
'''Initialize a dialog.
Arguments:
parent -- a parent window (the application window)
title -- the dialog title
+
+ use_ttk -- use the classic Tk widgets (the default), or the themed
+ (ttk) widgets if true
'''
+ # Use the classic Tk widgets by default, for compatibility: the themed
+ # (ttk) widgets set a themed background that classic widgets added by a
+ # subclass in body() would not match. The query dialogs opt into ttk.
+ self.use_ttk = use_ttk
+
master = parent
if master is None:
master = _get_temp_root()
- Toplevel.__init__(self, master)
+ Toplevel.__init__(self, master, class_='Dialog')
+
+ if self.use_ttk:
+ # Use a single background colour for the whole dialog so that it
+ # blends with the ttk widgets (cf. tk::MessageBox).
+ self.configure(
+ background=ttk.Style(self).lookup('.', 'background'))
self.withdraw() # remain invisible for now
# If the parent is not viewable, don't
if parent is not None and parent.winfo_viewable():
self.transient(parent)
- if title:
- self.title(title)
+ self.title(title or ' ')
+ self.iconname('Dialog')
_setup_dialog(self)
self.result = None
- body = Frame(self)
+ body = self._frame('top')
self.initial_focus = self.body(body)
- body.pack(padx=5, pady=5)
+ body.pack(side=TOP, fill=BOTH, expand=1)
self.buttonbox()
_place_window(self, parent)
- self.initial_focus.focus_set()
-
# wait for window to appear on screen before calling grab_set
self.wait_visibility()
- self.grab_set()
- self.wait_window(self)
+ # Dialog destroys itself in ok()/cancel(), so let _temp_grab_focus
+ # save/restore the focus and grab without destroying the window.
+ with _temp_grab_focus(self, self.initial_focus, destroy=False):
+ self.wait_window(self)
def destroy(self):
'''Destroy the window'''
override if you do not want the standard buttons
'''
- box = Frame(self)
-
- w = Button(box, text="OK", width=10, command=self.ok, default=ACTIVE)
- w.pack(side=LEFT, padx=5, pady=5)
- w = Button(box, text="Cancel", width=10, command=self.cancel)
- w.pack(side=LEFT, padx=5, pady=5)
-
- self.bind("<Return>", self.ok)
- self.bind("<Escape>", self.cancel)
+ box = self._frame('bot')
+
+ # tk::MessageBox and tk_dialog space the buttons differently.
+ padx, pady = ('3m', '2m') if self.use_ttk else ('7.5p', '3p')
+ for i, (name, label, command) in enumerate(
+ (('ok', '&OK', self.ok), ('cancel', '&Cancel', self.cancel))):
+ # Create a button with an accelerator key marked by "&" in the text
+ # (cf. tk::AmpWidget).
+ text, underline = _underline_ampersand(label)
+ b = self._widget('Button', self, name=name, text=text,
+ underline=underline, command=command,
+ default=ACTIVE if name == 'ok' else NORMAL)
+ b.bind('<<AltUnderlined>>', lambda e: e.widget.invoke())
+ b.grid(in_=box, column=i, row=0, sticky=EW, padx=padx, pady=pady)
+ # tk::MessageBox makes the buttons equal width; tk_dialog does not.
+ box.grid_columnconfigure(i, uniform='buttons' if self.use_ttk else '')
+ if self._windowingsystem == 'aqua':
+ box.grid_columnconfigure(i, minsize=90)
+ b.grid_configure(pady=7)
+
+ # Alt + an underlined character invokes the matching button (cf.
+ # ::tk::AltKeyInDialog, bound by tk::MessageBox).
+ self.bind('<Alt-Key>', self._alt_key)
+ # The default ring follows the keyboard focus among the buttons
+ # (cf. tk::MessageBox).
+ self.bind('<FocusIn>', lambda e: self._set_default(e.widget, ACTIVE))
+ self.bind('<FocusOut>', lambda e: self._set_default(e.widget, NORMAL))
+ self.bind('<Return>', self._return_event)
+ self.bind('<Escape>', self.cancel)
+
+ box.pack(side=BOTTOM, fill=BOTH)
+ box.grid_anchor(CENTER)
+
+ def _set_default(self, widget, state):
+ # Set a button's default ring.
+ if widget.winfo_class() in ('Button', 'TButton'):
+ widget.configure(default=state)
+
+ def _return_event(self, event):
+ # Invoke the focused button, or accept the dialog if the focus is
+ # elsewhere (e.g. in an entry).
+ widget = event.widget
+ if widget.winfo_class() in ('Button', 'TButton'):
+ widget.invoke()
+ else:
+ self.ok()
- box.pack()
+ def _alt_key(self, event):
+ # Invoke the button whose accelerator matches the Alt key.
+ target = _find_alt_key_target(self, event.char)
+ if target is not None:
+ target.event_generate('<<AltUnderlined>>')
#
# standard button semantics
self.cancel()
def cancel(self, event=None):
-
- # put focus back to the parent window
- if self.parent is not None:
- self.parent.focus_set()
self.destroy()
#
dialog is destroyed. By default, it always validates OK.
'''
- return 1 # override
+ return True # override
def apply(self):
'''process the data
# Place a toplevel window at the center of parent or screen
-# It is a Python implementation of ::tk::PlaceWindow.
+# This is a Python implementation of ::tk::PlaceWindow.
+def _wm_dimension(w, command):
+ # tk::WMFrameWidth and tk::WMTitleHeight (added in Tk 9.1) return the size
+ # of the window manager decoration. They are 0 except in SDL2 builds of
+ # Tk, and are missing in older versions.
+ try:
+ return int(w.tk.call(command))
+ except TclError:
+ return 0
+
+
def _place_window(w, parent=None):
w.wm_withdraw() # Remain invisible while we figure out the geometry
w.update_idletasks() # Actualize geometry information
+ screenwidth = w.winfo_screenwidth()
+ screenheight = w.winfo_screenheight()
minwidth = w.winfo_reqwidth()
minheight = w.winfo_reqheight()
maxwidth = w.winfo_vrootwidth()
maxheight = w.winfo_vrootheight()
+ # "wm geometry" operates in window manager coordinates and thus includes a
+ # possible decoration frame and the title bar.
+ framewidth = _wm_dimension(w, '::tk::WMFrameWidth')
+ titleheight = _wm_dimension(w, '::tk::WMTitleHeight')
+ constrain = False
+ if minwidth + 2*framewidth > screenwidth:
+ minwidth = screenwidth - 2*framewidth
+ constrain = True
+ if minheight + titleheight + framewidth > screenheight:
+ minheight = screenheight - titleheight - framewidth
+ constrain = True
+
if parent is not None and parent.winfo_ismapped():
+ # Center the window over the parent (which must be mapped).
x = parent.winfo_rootx() + (parent.winfo_width() - minwidth) // 2
y = parent.winfo_rooty() + (parent.winfo_height() - minheight) // 2
+ # Make sure that the window is on the screen and does not cover the
+ # window manager decoration.
vrootx = w.winfo_vrootx()
vrooty = w.winfo_vrooty()
- x = min(x, vrootx + maxwidth - minwidth)
- x = max(x, vrootx)
- y = min(y, vrooty + maxheight - minheight)
- y = max(y, vrooty)
+ x = min(x, vrootx + maxwidth - minwidth - framewidth)
+ x = max(x, vrootx + framewidth)
+ y = min(y, vrooty + maxheight - minheight - framewidth)
+ y = max(y, vrooty + titleheight)
if w._windowingsystem == 'aqua':
# Avoid the native menu bar which sits on top of everything.
- y = max(y, 22)
+ y = max(y, 22 + titleheight)
else:
- x = (w.winfo_screenwidth() - minwidth) // 2
- y = (w.winfo_screenheight() - minheight) // 2
+ # Center the window on the screen.
+ x = (screenwidth - minwidth) // 2
+ y = (screenheight - minheight) // 2
w.wm_maxsize(maxwidth, maxheight)
- w.wm_geometry('+%d+%d' % (x, y))
+ geometry = f'{minwidth}x{minheight}' if constrain else ''
+ geometry += '+%d+%d' % (x - framewidth, y - titleheight)
+ w.wm_geometry(geometry)
w.wm_deiconify() # Become visible at the desired location
+def _underline_ampersand(text):
+ # Like tk::UnderlineAmpersand: "&&" is a literal "&"; a single "&" marks
+ # the following character as the underlined accelerator. Return the text
+ # without the markers and the index of the accelerator (-1 if none).
+ chars = []
+ underline = -1
+ i = 0
+ while i < len(text):
+ if text[i] == '&':
+ if text[i+1:i+2] == '&':
+ chars.append('&')
+ i += 2
+ continue
+ if underline < 0:
+ underline = len(chars)
+ else:
+ chars.append(text[i])
+ i += 1
+ return ''.join(chars), underline
+
+
+def _find_alt_key_target(widget, char):
+ # Like tk::FindAltKeyTarget: find the widget whose underlined character
+ # matches CHAR, searching the widget and its descendants.
+ if widget.winfo_class() in ('Button', 'Checkbutton', 'Label', 'Radiobutton',
+ 'TButton', 'TCheckbutton', 'TLabel',
+ 'TRadiobutton'):
+ try:
+ under = int(widget.cget('underline'))
+ except (TclError, ValueError):
+ under = -1
+ text = str(widget.cget('text'))
+ if 0 <= under < len(text) and char.lower() == text[under].lower():
+ return widget
+ for child in widget.winfo_children():
+ target = _find_alt_key_target(child, char)
+ if target is not None:
+ return target
+ return None
+
+
def _setup_dialog(w):
if w._windowingsystem == "aqua":
- w.tk.call("::tk::unsupported::MacWindowStyle", "style",
- w, "moveableModal", "")
+ if w.info_patchlevel() >= (9, 1):
+ w.wm_attributes(stylemask='titled')
+ else:
+ w.tk.call('::tk::unsupported::MacWindowStyle', 'style',
+ w, 'moveableModal', '')
elif w._windowingsystem == "x11":
w.wm_attributes(type="dialog")
def __init__(self, title, prompt,
initialvalue=None,
minvalue = None, maxvalue = None,
- parent = None):
+ parent = None, *, use_ttk=True):
self.prompt = prompt
self.minvalue = minvalue
self.initialvalue = initialvalue
- Dialog.__init__(self, parent, title)
+ Dialog.__init__(self, parent, title, use_ttk=use_ttk)
def destroy(self):
self.entry = None
def body(self, master):
- w = Label(master, text=self.prompt, justify=LEFT)
- w.grid(row=0, padx=5, sticky=W)
+ # Wrap a long prompt, like tk::MessageBox wraps its message at 3 inches.
+ w = self._widget('Label', master, anchor=NW, text=self.prompt,
+ justify=LEFT, wraplength='3i')
+ w.grid(in_=master, padx='2m', pady='2m', sticky=NSEW)
- self.entry = Entry(master, name="entry")
- self.entry.grid(row=1, padx=5, sticky=W+E)
+ self.entry = self._widget('Entry', master, name='entry')
+ self.entry.grid(row=1, in_=master, padx='2m', pady=(0, '2m'), sticky=NSEW)
+ master.grid_rowconfigure(1, weight=1)
+ # The prompt and entry expand to the full width, like tk::MessageBox
+ # gives weight to its message column.
+ master.grid_columnconfigure(0, weight=1)
if self.initialvalue is not None:
self.entry.insert(0, self.initialvalue)
self.errormessage + "\nPlease try again",
parent = self
)
- return 0
+ return False
if self.minvalue is not None and result < self.minvalue:
messagebox.showwarning(
"Please try again." % self.minvalue,
parent = self
)
- return 0
+ return False
if self.maxvalue is not None and result > self.maxvalue:
messagebox.showwarning(
"Please try again." % self.maxvalue,
parent = self
)
- return 0
+ return False
self.result = result
- return 1
+ return True
class _QueryInteger(_QueryDialog):
return d.result
+@contextlib.contextmanager
+def _temp_grab_focus(grab, focus=None, destroy=True):
+ old_focus = grab.focus_get()
+ old_grab = grab.grab_current()
+ if old_grab is not None and old_grab.winfo_exists():
+ old_status = old_grab.grab_status()
+ else:
+ old_status = None
+ # The "grab" command will fail if another application
+ # already holds the grab. So catch it.
+ try:
+ grab.grab_set()
+ except TclError:
+ pass
+ try:
+ if focus is not None and focus.winfo_exists():
+ focus.focus_set()
+
+ yield
+
+ finally:
+ if old_focus is not None:
+ try:
+ old_focus.focus_set()
+ except TclError:
+ pass
+ try:
+ grab.grab_release()
+ except TclError:
+ pass
+ if destroy:
+ try:
+ grab.destroy()
+ except TclError:
+ pass
+ if (old_grab is not None and old_grab.winfo_exists()
+ and old_grab.winfo_ismapped()):
+ # The "grab" command will fail if another application
+ # already holds the grab. So catch it.
+ try:
+ if old_status == 'global':
+ old_grab.grab_set_global()
+ else:
+ old_grab.grab_set()
+ except TclError:
+ pass
+
+
if __name__ == '__main__':
def test():
root = Tk()
- def doit(root=root):
+ use_ttk = tkinter.BooleanVar(root, value=True)
+
+ def test_dialog():
d = SimpleDialog(root,
text="This is a test dialog. "
"Would this have been an actual dialog, "
"the buttons below would have been glowing "
"in soft pink light.\n"
"Do you believe this?",
- buttons=["Yes", "No", "Cancel"],
+ buttons=[{'text': 'Yes', 'underline': 0},
+ {'text': 'No', 'underline': 0},
+ {'text': 'Cancel', 'underline': 0}],
default=0,
cancel=2,
- title="Test Dialog")
+ title="Test Dialog",
+ bitmap='question',
+ detail="Alt+Y, Alt+N and Alt+C work too.",
+ use_ttk=use_ttk.get())
print(d.go())
- print(askinteger("Spam", "Egg count", initialvalue=12*12))
+
+ def test_integer():
+ print(askinteger("Spam", "Egg count", initialvalue=12*12,
+ use_ttk=use_ttk.get()))
+
+ def test_float():
print(askfloat("Spam", "Egg weight\n(in tons)", minvalue=1,
- maxvalue=100))
- print(askstring("Spam", "Egg label"))
- t = Button(root, text='Test', command=doit)
- t.pack()
- q = Button(root, text='Quit', command=t.quit)
- q.pack()
- t.mainloop()
+ maxvalue=100, use_ttk=use_ttk.get()))
+
+ def test_string():
+ print(askstring("Spam", "Egg label", use_ttk=use_ttk.get()))
+
+ tkinter.Checkbutton(root, text='Use themed (ttk) widgets',
+ variable=use_ttk).pack(fill=X)
+ Button(root, text='SimpleDialog', command=test_dialog).pack(fill=X)
+ Button(root, text='askinteger', command=test_integer).pack(fill=X)
+ Button(root, text='askfloat', command=test_float).pack(fill=X)
+ Button(root, text='askstring', command=test_string).pack(fill=X)
+ Button(root, text='Quit', command=root.quit).pack(fill=X)
+ root.mainloop()
test()