Skip to content

Commit f2bdda3

Browse files
gh-72880: Add tkinter.fontchooser -- interface to the font selection dialog
The FontChooser class wraps the "tk fontchooser" command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2cd5b79 commit f2bdda3

7 files changed

Lines changed: 365 additions & 0 deletions

File tree

Doc/library/tk.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ alternative `GUI frameworks and tools <https://wiki.python.org/moin/GuiProgrammi
3333
tkinter.rst
3434
tkinter.colorchooser.rst
3535
tkinter.font.rst
36+
tkinter.fontchooser.rst
3637
dialog.rst
3738
tkinter.messagebox.rst
3839
tkinter.scrolledtext.rst
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
:mod:`!tkinter.fontchooser` --- Font selection dialog
2+
=====================================================
3+
4+
.. module:: tkinter.fontchooser
5+
:synopsis: Font selection dialog
6+
7+
.. versionadded:: next
8+
9+
**Source code:** :source:`Lib/tkinter/fontchooser.py`
10+
11+
--------------
12+
13+
The :mod:`!tkinter.fontchooser` module provides the :class:`FontChooser` class
14+
as an interface to the native font selection dialog.
15+
16+
The font dialog is application-global:
17+
there is a single font dialog per Tcl interpreter,
18+
and all :class:`FontChooser` instances configure the same dialog.
19+
20+
Depending on the platform, the dialog may be modal or modeless,
21+
so :meth:`~FontChooser.show` may return immediately.
22+
The selected font is not returned:
23+
it is passed as a :class:`~tkinter.font.Font` object to the callback
24+
specified with the *command* option.
25+
26+
The dialog also generates two virtual events on the parent window
27+
(see the *parent* option):
28+
29+
``<<TkFontchooserVisibility>>``
30+
Generated when the dialog is shown or hidden.
31+
Query the *visible* option to tell which.
32+
33+
``<<TkFontchooserFontChanged>>``
34+
Generated when the selected font changes.
35+
36+
.. note::
37+
38+
The *command* callback is the only reliable way to obtain the
39+
selected font.
40+
On some platforms the *font* option is not updated to the user's
41+
choice.
42+
43+
.. class:: FontChooser(master=None, *, parent=None, title=None, font=None, command=None)
44+
45+
The class implementing the font selection dialog.
46+
47+
*master* is the widget whose Tcl interpreter owns the dialog.
48+
If omitted, it defaults to *parent* if that is given,
49+
or to the default root window otherwise.
50+
51+
The supported configuration options are:
52+
53+
* *parent* --- the window to which the dialog and its virtual events
54+
are related.
55+
It defaults to the main window;
56+
on macOS the dialog is shown as a sheet attached to it,
57+
rather than as a free-standing panel.
58+
* *title* --- the title of the dialog.
59+
* *font* --- the font that is currently selected in the dialog.
60+
* *command* --- a callback that is called with a
61+
:class:`~tkinter.font.Font` object wrapping the selected font when
62+
the user selects a font.
63+
* *visible* --- whether the dialog is currently displayed (read-only).
64+
65+
The *font* option accepts the forms supported by
66+
:class:`tkinter.font.Font`.
67+
68+
.. method:: configure(**options)
69+
config(**options)
70+
71+
Query or modify the options of the font dialog.
72+
With no arguments, return a dict of all option values.
73+
With a string argument, return the value of that option.
74+
Otherwise, set the given options.
75+
76+
.. method:: cget(option)
77+
78+
Return the value of the given option of the font dialog.
79+
80+
.. method:: show()
81+
82+
Display the font dialog.
83+
Depending on the platform, this method may return immediately
84+
or only once the dialog has been withdrawn.
85+
86+
.. method:: hide()
87+
88+
Hide the font dialog if it is displayed.
89+
90+
91+
.. seealso::
92+
93+
Module :mod:`tkinter.font`
94+
Tkinter font-handling utilities

Doc/library/tkinter.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ The modules that provide Tk support include:
146146
:mod:`tkinter.font`
147147
Utilities to help work with fonts.
148148

149+
:mod:`tkinter.fontchooser`
150+
Dialog to let the user choose a font.
151+
149152
:mod:`tkinter.messagebox`
150153
Access to standard Tk dialog boxes.
151154

Doc/whatsnew/3.16.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,11 @@ tkinter
414414
:meth:`~tkinter.font.Font.measure` and :meth:`~tkinter.font.Font.metrics`.
415415
(Contributed by Serhiy Storchaka in :gh:`143990`.)
416416

417+
* Added the :mod:`tkinter.fontchooser` module which provides the
418+
:class:`~tkinter.fontchooser.FontChooser` class as an interface to the
419+
native font selection dialog.
420+
(Contributed by Serhiy Storchaka in :gh:`72880`.)
421+
417422
xml
418423
---
419424

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import time
2+
import unittest
3+
import tkinter
4+
from tkinter import font
5+
from tkinter.fontchooser import FontChooser
6+
from test import support
7+
from test.support import requires
8+
from test.test_tkinter.support import (AbstractTkTest,
9+
AbstractDefaultRootTest,
10+
setUpModule) # noqa: F401
11+
12+
requires('gui')
13+
14+
15+
class FontChooserTest(AbstractTkTest, unittest.TestCase):
16+
17+
def setUp(self):
18+
super().setUp()
19+
self.fc = FontChooser(self.root)
20+
self.addCleanup(self._reset)
21+
22+
def _reset(self):
23+
# The font dialog is global for the interpreter, so restore
24+
# a clean state for the next test.
25+
try:
26+
self.fc.configure(parent=self.root, title='', command=None)
27+
self.fc.hide()
28+
except tkinter.TclError:
29+
pass
30+
31+
def _visible(self):
32+
return self.root.tk.getboolean(self.fc.cget('visible'))
33+
34+
def _wait_visibility(self, expected, timeout=None):
35+
# Bounded wait until the dialog visibility matches expected.
36+
if timeout is None:
37+
timeout = support.LOOPBACK_TIMEOUT
38+
deadline = time.monotonic() + timeout
39+
while time.monotonic() < deadline:
40+
self.root.update()
41+
if self._visible() == expected:
42+
return True
43+
time.sleep(0.01)
44+
return False
45+
46+
def test_configure_query(self):
47+
options = self.fc.configure()
48+
self.assertIsInstance(options, dict)
49+
self.assertLessEqual({'parent', 'title', 'font', 'command', 'visible'},
50+
options.keys())
51+
52+
def test_configure(self):
53+
self.fc.configure(title='Pick a font')
54+
if self.root._windowingsystem == 'x11':
55+
self.assertEqual(self.fc.cget('title'), 'Pick a font')
56+
self.fc.configure(font='Courier 10')
57+
self.assertTrue(self.fc.cget('font'))
58+
if self.root._windowingsystem == 'x11':
59+
self.assertEqual(str(self.fc.cget('font')), 'Courier 10')
60+
61+
def test_parent(self):
62+
top = tkinter.Toplevel(self.root)
63+
self.addCleanup(top.destroy)
64+
# The constructor does not force -parent to the master: it stays
65+
# the main window even when the master is another widget.
66+
FontChooser(top)
67+
self.assertEqual(self.fc.cget('parent'), str(self.root))
68+
# -parent can be set explicitly.
69+
self.fc.configure(parent=top)
70+
self.assertEqual(self.fc.cget('parent'), str(top))
71+
72+
def test_configure_font_instance(self):
73+
# A Font instance can be passed as the font, both to the
74+
# constructor and to configure().
75+
f = font.Font(self.root, family='Courier', size=14, weight='bold')
76+
fc = FontChooser(self.root, font=f)
77+
self.assertEqual(str(fc.cget('font')), str(f))
78+
f2 = font.Font(self.root, family='Times', size=11)
79+
fc.configure(font=f2)
80+
self.assertEqual(str(fc.cget('font')), str(f2))
81+
82+
def test_configure_visible_readonly(self):
83+
with self.assertRaises(tkinter.TclError):
84+
self.fc.configure(visible=True)
85+
86+
def test_cget_visible(self):
87+
self.assertFalse(self._visible())
88+
89+
def test_command(self):
90+
result = []
91+
self.fc.configure(command=result.append)
92+
name = self.fc._command_name
93+
self.assertTrue(name)
94+
# The Tcl command is named after the wrapped callback.
95+
self.assertTrue(name.endswith('append'), name)
96+
# The callback receives a Font wrapping the selected font.
97+
self.root.tk.call(name, 'Courier 10')
98+
self.assertEqual(len(result), 1)
99+
selected = result[0]
100+
self.assertIsInstance(selected, font.Font)
101+
# The description is wrapped without creating a named font.
102+
self.assertEqual(str(selected), 'Courier 10')
103+
self.assertFalse(selected.delete_font)
104+
self.assertEqual(selected.actual('size'), 10)
105+
# Replacing the callback deletes the old Tcl command.
106+
self.fc.configure(command=lambda font: None)
107+
self.assertNotEqual(self.fc._command_name, name)
108+
self.assertFalse(self.root.tk.call('info', 'commands', name))
109+
# Removing the callback deletes the Tcl command.
110+
name = self.fc._command_name
111+
self.fc.configure(command=None)
112+
self.assertIsNone(self.fc._command_name)
113+
self.assertFalse(self.root.tk.call('info', 'commands', name))
114+
self.assertEqual(self.fc.cget('command'), '')
115+
116+
def test_show_hide(self):
117+
if self.root._windowingsystem != 'x11':
118+
self.skipTest('cannot safely drive the native font dialog')
119+
events = []
120+
self.root.bind('<<TkFontchooserVisibility>>', events.append)
121+
self.fc.show()
122+
if not self._wait_visibility(True):
123+
self.skipTest('the font dialog was not mapped')
124+
self.fc.hide()
125+
self.assertTrue(self._wait_visibility(False))
126+
self.assertTrue(events)
127+
128+
129+
class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase):
130+
131+
def test_fontchooser(self):
132+
root = tkinter.Tk()
133+
fc = FontChooser()
134+
self.assertIs(fc.master, root)
135+
root.destroy()
136+
tkinter.NoDefaultRoot()
137+
self.assertRaises(RuntimeError, FontChooser)
138+
139+
140+
if __name__ == "__main__":
141+
unittest.main()

Lib/tkinter/fontchooser.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Interface to the native font selection dialog.
2+
3+
The FontChooser class gives access to the "tk fontchooser" command.
4+
The dialog is application-global: all instances configure the single
5+
dialog of the Tcl interpreter.
6+
7+
The dialog may be modeless, so show() may return immediately.
8+
"""
9+
10+
import tkinter
11+
from tkinter.font import Font
12+
13+
__all__ = ["FontChooser"]
14+
15+
16+
class FontChooser:
17+
"""The font selection dialog.
18+
19+
Supported configuration options are:
20+
21+
parent: the window to which the dialog and its virtual
22+
events are related (the main window by default)
23+
title: the title of the dialog
24+
font: the font that is currently selected in the dialog
25+
command: a callback that is called with a tkinter.font.Font
26+
object wrapping the selected font when the user
27+
selects a font
28+
visible: whether the dialog is currently displayed
29+
(read-only)
30+
31+
The "font" option accepts the forms supported by
32+
tkinter.font.Font(font=...).
33+
34+
The "command" callback is the only reliable way to obtain the
35+
selected font. On some platforms the "font" option is not updated
36+
to the user's choice.
37+
"""
38+
39+
def __init__(self, master=None, *, parent=None, title=None, font=None,
40+
command=None):
41+
if master is None:
42+
master = parent
43+
if master is None:
44+
master = tkinter._get_default_root('create a font chooser')
45+
self.master = master
46+
self._command_name = None
47+
options = {}
48+
if parent is not None:
49+
options['parent'] = parent
50+
if title is not None:
51+
options['title'] = title
52+
if font is not None:
53+
options['font'] = font
54+
if command is not None:
55+
options['command'] = command
56+
if options:
57+
self.configure(options)
58+
59+
def configure(self, cnf=None, **kw):
60+
"""Query or modify the options of the font dialog.
61+
62+
With no arguments, return a dict of all option values. With a
63+
string argument, return the value of that option. Otherwise,
64+
set the given options. The "visible" option is read-only.
65+
"""
66+
if kw:
67+
cnf = tkinter._cnfmerge((cnf, kw))
68+
elif cnf:
69+
cnf = tkinter._cnfmerge(cnf)
70+
master = self.master
71+
if cnf is None:
72+
items = master.tk.splitlist(master.tk.call(
73+
'tk', 'fontchooser', 'configure'))
74+
return {items[i][1:]: items[i+1] for i in range(0, len(items), 2)}
75+
if isinstance(cnf, str):
76+
return master.tk.call('tk', 'fontchooser', 'configure', '-' + cnf)
77+
cnf = dict(cnf)
78+
new_name = None
79+
if 'command' in cnf:
80+
command = cnf['command']
81+
if command is None:
82+
cnf['command'] = ''
83+
elif callable(command):
84+
# Pass the selected font to the callback as a Font object.
85+
def callback(description):
86+
command(Font(self.master, font=description, exists=True))
87+
# Name the Tcl command after the callback.
88+
callback.__func__ = command
89+
new_name = cnf['command'] = master._register(callback)
90+
try:
91+
master.tk.call('tk', 'fontchooser', 'configure',
92+
*master._options(cnf))
93+
except tkinter.TclError:
94+
if new_name is not None:
95+
master.deletecommand(new_name)
96+
raise
97+
if 'command' in cnf:
98+
if self._command_name is not None:
99+
master.deletecommand(self._command_name)
100+
self._command_name = new_name
101+
config = configure
102+
103+
def cget(self, option):
104+
"""Return the value of the given option of the font dialog."""
105+
return self.master.tk.call('tk', 'fontchooser', 'configure',
106+
'-' + option)
107+
108+
def show(self):
109+
"""Display the font dialog.
110+
111+
Depending on the platform, may return immediately or only
112+
once the dialog has been withdrawn.
113+
"""
114+
self.master.tk.call('tk', 'fontchooser', 'show')
115+
116+
def hide(self):
117+
"""Hide the font dialog if it is displayed."""
118+
self.master.tk.call('tk', 'fontchooser', 'hide')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Added the :mod:`tkinter.fontchooser` module which provides the
2+
:class:`~tkinter.fontchooser.FontChooser` class as an interface to the
3+
native font selection dialog.

0 commit comments

Comments
 (0)