From f2bdda397628cd8424eab1ff2b6d009946e74779 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 6 Jul 2026 23:15:41 +0300 Subject: [PATCH 1/2] 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 --- Doc/library/tk.rst | 1 + Doc/library/tkinter.fontchooser.rst | 94 ++++++++++++ Doc/library/tkinter.rst | 3 + Doc/whatsnew/3.16.rst | 5 + Lib/test/test_tkinter/test_fontchooser.py | 141 ++++++++++++++++++ Lib/tkinter/fontchooser.py | 118 +++++++++++++++ ...6-07-06-13-42-17.gh-issue-72880.fcXhWr.rst | 3 + 7 files changed, 365 insertions(+) create mode 100644 Doc/library/tkinter.fontchooser.rst create mode 100644 Lib/test/test_tkinter/test_fontchooser.py create mode 100644 Lib/tkinter/fontchooser.py create mode 100644 Misc/NEWS.d/next/Library/2026-07-06-13-42-17.gh-issue-72880.fcXhWr.rst diff --git a/Doc/library/tk.rst b/Doc/library/tk.rst index fa3c7e910ce21f0..ef435b21606f681 100644 --- a/Doc/library/tk.rst +++ b/Doc/library/tk.rst @@ -33,6 +33,7 @@ alternative `GUI frameworks and tools >`` + Generated when the dialog is shown or hidden. + Query the *visible* option to tell which. + +``<>`` + Generated when the selected font changes. + +.. note:: + + The *command* callback is the only reliable way to obtain the + selected font. + On some platforms the *font* option is not updated to the user's + choice. + +.. class:: FontChooser(master=None, *, parent=None, title=None, font=None, command=None) + + The class implementing the font selection dialog. + + *master* is the widget whose Tcl interpreter owns the dialog. + If omitted, it defaults to *parent* if that is given, + or to the default root window otherwise. + + The supported configuration options are: + + * *parent* --- the window to which the dialog and its virtual events + are related. + It defaults to the main window; + on macOS the dialog is shown as a sheet attached to it, + rather than as a free-standing panel. + * *title* --- the title of the dialog. + * *font* --- the font that is currently selected in the dialog. + * *command* --- a callback that is called with a + :class:`~tkinter.font.Font` object wrapping the selected font when + the user selects a font. + * *visible* --- whether the dialog is currently displayed (read-only). + + The *font* option accepts the forms supported by + :class:`tkinter.font.Font`. + + .. method:: configure(**options) + config(**options) + + Query or modify the options of the font dialog. + With no arguments, return a dict of all option values. + With a string argument, return the value of that option. + Otherwise, set the given options. + + .. method:: cget(option) + + Return the value of the given option of the font dialog. + + .. method:: show() + + Display the font dialog. + Depending on the platform, this method may return immediately + or only once the dialog has been withdrawn. + + .. method:: hide() + + Hide the font dialog if it is displayed. + + +.. seealso:: + + Module :mod:`tkinter.font` + Tkinter font-handling utilities diff --git a/Doc/library/tkinter.rst b/Doc/library/tkinter.rst index ca034fcdc09b439..9c4b815b15e6eef 100644 --- a/Doc/library/tkinter.rst +++ b/Doc/library/tkinter.rst @@ -146,6 +146,9 @@ The modules that provide Tk support include: :mod:`tkinter.font` Utilities to help work with fonts. +:mod:`tkinter.fontchooser` + Dialog to let the user choose a font. + :mod:`tkinter.messagebox` Access to standard Tk dialog boxes. diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index dd2c7b68d039921..ee9b15efb01b427 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -414,6 +414,11 @@ tkinter :meth:`~tkinter.font.Font.measure` and :meth:`~tkinter.font.Font.metrics`. (Contributed by Serhiy Storchaka in :gh:`143990`.) +* Added the :mod:`tkinter.fontchooser` module which provides the + :class:`~tkinter.fontchooser.FontChooser` class as an interface to the + native font selection dialog. + (Contributed by Serhiy Storchaka in :gh:`72880`.) + xml --- diff --git a/Lib/test/test_tkinter/test_fontchooser.py b/Lib/test/test_tkinter/test_fontchooser.py new file mode 100644 index 000000000000000..64c6153763aa6fd --- /dev/null +++ b/Lib/test/test_tkinter/test_fontchooser.py @@ -0,0 +1,141 @@ +import time +import unittest +import tkinter +from tkinter import font +from tkinter.fontchooser import FontChooser +from test import support +from test.support import requires +from test.test_tkinter.support import (AbstractTkTest, + AbstractDefaultRootTest, + setUpModule) # noqa: F401 + +requires('gui') + + +class FontChooserTest(AbstractTkTest, unittest.TestCase): + + def setUp(self): + super().setUp() + self.fc = FontChooser(self.root) + self.addCleanup(self._reset) + + def _reset(self): + # The font dialog is global for the interpreter, so restore + # a clean state for the next test. + try: + self.fc.configure(parent=self.root, title='', command=None) + self.fc.hide() + except tkinter.TclError: + pass + + def _visible(self): + return self.root.tk.getboolean(self.fc.cget('visible')) + + def _wait_visibility(self, expected, timeout=None): + # Bounded wait until the dialog visibility matches expected. + if timeout is None: + timeout = support.LOOPBACK_TIMEOUT + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + self.root.update() + if self._visible() == expected: + return True + time.sleep(0.01) + return False + + def test_configure_query(self): + options = self.fc.configure() + self.assertIsInstance(options, dict) + self.assertLessEqual({'parent', 'title', 'font', 'command', 'visible'}, + options.keys()) + + def test_configure(self): + self.fc.configure(title='Pick a font') + if self.root._windowingsystem == 'x11': + self.assertEqual(self.fc.cget('title'), 'Pick a font') + self.fc.configure(font='Courier 10') + self.assertTrue(self.fc.cget('font')) + if self.root._windowingsystem == 'x11': + self.assertEqual(str(self.fc.cget('font')), 'Courier 10') + + def test_parent(self): + top = tkinter.Toplevel(self.root) + self.addCleanup(top.destroy) + # The constructor does not force -parent to the master: it stays + # the main window even when the master is another widget. + FontChooser(top) + self.assertEqual(self.fc.cget('parent'), str(self.root)) + # -parent can be set explicitly. + self.fc.configure(parent=top) + self.assertEqual(self.fc.cget('parent'), str(top)) + + def test_configure_font_instance(self): + # A Font instance can be passed as the font, both to the + # constructor and to configure(). + f = font.Font(self.root, family='Courier', size=14, weight='bold') + fc = FontChooser(self.root, font=f) + self.assertEqual(str(fc.cget('font')), str(f)) + f2 = font.Font(self.root, family='Times', size=11) + fc.configure(font=f2) + self.assertEqual(str(fc.cget('font')), str(f2)) + + def test_configure_visible_readonly(self): + with self.assertRaises(tkinter.TclError): + self.fc.configure(visible=True) + + def test_cget_visible(self): + self.assertFalse(self._visible()) + + def test_command(self): + result = [] + self.fc.configure(command=result.append) + name = self.fc._command_name + self.assertTrue(name) + # The Tcl command is named after the wrapped callback. + self.assertTrue(name.endswith('append'), name) + # The callback receives a Font wrapping the selected font. + self.root.tk.call(name, 'Courier 10') + self.assertEqual(len(result), 1) + selected = result[0] + self.assertIsInstance(selected, font.Font) + # The description is wrapped without creating a named font. + self.assertEqual(str(selected), 'Courier 10') + self.assertFalse(selected.delete_font) + self.assertEqual(selected.actual('size'), 10) + # Replacing the callback deletes the old Tcl command. + self.fc.configure(command=lambda font: None) + self.assertNotEqual(self.fc._command_name, name) + self.assertFalse(self.root.tk.call('info', 'commands', name)) + # Removing the callback deletes the Tcl command. + name = self.fc._command_name + self.fc.configure(command=None) + self.assertIsNone(self.fc._command_name) + self.assertFalse(self.root.tk.call('info', 'commands', name)) + self.assertEqual(self.fc.cget('command'), '') + + def test_show_hide(self): + if self.root._windowingsystem != 'x11': + self.skipTest('cannot safely drive the native font dialog') + events = [] + self.root.bind('<>', events.append) + self.fc.show() + if not self._wait_visibility(True): + self.skipTest('the font dialog was not mapped') + self.fc.hide() + self.assertTrue(self._wait_visibility(False)) + self.assertTrue(events) + + +class DefaultRootTest(AbstractDefaultRootTest, unittest.TestCase): + + def test_fontchooser(self): + root = tkinter.Tk() + fc = FontChooser() + self.assertIs(fc.master, root) + root.destroy() + tkinter.NoDefaultRoot() + self.assertRaises(RuntimeError, FontChooser) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/tkinter/fontchooser.py b/Lib/tkinter/fontchooser.py new file mode 100644 index 000000000000000..03d8ba1ef6c010e --- /dev/null +++ b/Lib/tkinter/fontchooser.py @@ -0,0 +1,118 @@ +"""Interface to the native font selection dialog. + +The FontChooser class gives access to the "tk fontchooser" command. +The dialog is application-global: all instances configure the single +dialog of the Tcl interpreter. + +The dialog may be modeless, so show() may return immediately. +""" + +import tkinter +from tkinter.font import Font + +__all__ = ["FontChooser"] + + +class FontChooser: + """The font selection dialog. + + Supported configuration options are: + + parent: the window to which the dialog and its virtual + events are related (the main window by default) + title: the title of the dialog + font: the font that is currently selected in the dialog + command: a callback that is called with a tkinter.font.Font + object wrapping the selected font when the user + selects a font + visible: whether the dialog is currently displayed + (read-only) + + The "font" option accepts the forms supported by + tkinter.font.Font(font=...). + + The "command" callback is the only reliable way to obtain the + selected font. On some platforms the "font" option is not updated + to the user's choice. + """ + + def __init__(self, master=None, *, parent=None, title=None, font=None, + command=None): + if master is None: + master = parent + if master is None: + master = tkinter._get_default_root('create a font chooser') + self.master = master + self._command_name = None + options = {} + if parent is not None: + options['parent'] = parent + if title is not None: + options['title'] = title + if font is not None: + options['font'] = font + if command is not None: + options['command'] = command + if options: + self.configure(options) + + def configure(self, cnf=None, **kw): + """Query or modify the options of the font dialog. + + With no arguments, return a dict of all option values. With a + string argument, return the value of that option. Otherwise, + set the given options. The "visible" option is read-only. + """ + if kw: + cnf = tkinter._cnfmerge((cnf, kw)) + elif cnf: + cnf = tkinter._cnfmerge(cnf) + master = self.master + if cnf is None: + items = master.tk.splitlist(master.tk.call( + 'tk', 'fontchooser', 'configure')) + return {items[i][1:]: items[i+1] for i in range(0, len(items), 2)} + if isinstance(cnf, str): + return master.tk.call('tk', 'fontchooser', 'configure', '-' + cnf) + cnf = dict(cnf) + new_name = None + if 'command' in cnf: + command = cnf['command'] + if command is None: + cnf['command'] = '' + elif callable(command): + # Pass the selected font to the callback as a Font object. + def callback(description): + command(Font(self.master, font=description, exists=True)) + # Name the Tcl command after the callback. + callback.__func__ = command + new_name = cnf['command'] = master._register(callback) + try: + master.tk.call('tk', 'fontchooser', 'configure', + *master._options(cnf)) + except tkinter.TclError: + if new_name is not None: + master.deletecommand(new_name) + raise + if 'command' in cnf: + if self._command_name is not None: + master.deletecommand(self._command_name) + self._command_name = new_name + config = configure + + def cget(self, option): + """Return the value of the given option of the font dialog.""" + return self.master.tk.call('tk', 'fontchooser', 'configure', + '-' + option) + + def show(self): + """Display the font dialog. + + Depending on the platform, may return immediately or only + once the dialog has been withdrawn. + """ + self.master.tk.call('tk', 'fontchooser', 'show') + + def hide(self): + """Hide the font dialog if it is displayed.""" + self.master.tk.call('tk', 'fontchooser', 'hide') diff --git a/Misc/NEWS.d/next/Library/2026-07-06-13-42-17.gh-issue-72880.fcXhWr.rst b/Misc/NEWS.d/next/Library/2026-07-06-13-42-17.gh-issue-72880.fcXhWr.rst new file mode 100644 index 000000000000000..688464c9435d3a9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-06-13-42-17.gh-issue-72880.fcXhWr.rst @@ -0,0 +1,3 @@ +Added the :mod:`tkinter.fontchooser` module which provides the +:class:`~tkinter.fontchooser.FontChooser` class as an interface to the +native font selection dialog. From 58240c4b9a5f2b576aab51c7836ec501f06def85 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 7 Jul 2026 14:07:45 +0300 Subject: [PATCH 2/2] gh-72880: Fix the fontchooser tests on macOS and with wantobjects=0 On macOS the native dialog stores the -font option as a resolved font description rather than the font name, and with wantobjects=0 Tcl returns strings instead of integers. Compare the actual font attributes and coerce the size to an integer. Co-Authored-By: Claude Opus 4.8 --- Lib/test/test_tkinter/test_fontchooser.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_tkinter/test_fontchooser.py b/Lib/test/test_tkinter/test_fontchooser.py index 64c6153763aa6fd..5136889288b4240 100644 --- a/Lib/test/test_tkinter/test_fontchooser.py +++ b/Lib/test/test_tkinter/test_fontchooser.py @@ -71,13 +71,17 @@ def test_parent(self): def test_configure_font_instance(self): # A Font instance can be passed as the font, both to the - # constructor and to configure(). + # constructor and to configure(). The dialog may store it as + # the font name or as a resolved description depending on the + # platform, so compare the actual attributes. + def actual(spec): + return font.Font(self.root, spec, exists=True).actual() f = font.Font(self.root, family='Courier', size=14, weight='bold') fc = FontChooser(self.root, font=f) - self.assertEqual(str(fc.cget('font')), str(f)) + self.assertEqual(actual(fc.cget('font')), f.actual()) f2 = font.Font(self.root, family='Times', size=11) fc.configure(font=f2) - self.assertEqual(str(fc.cget('font')), str(f2)) + self.assertEqual(actual(fc.cget('font')), f2.actual()) def test_configure_visible_readonly(self): with self.assertRaises(tkinter.TclError): @@ -101,7 +105,7 @@ def test_command(self): # The description is wrapped without creating a named font. self.assertEqual(str(selected), 'Courier 10') self.assertFalse(selected.delete_font) - self.assertEqual(selected.actual('size'), 10) + self.assertEqual(int(selected.actual('size')), 10) # Replacing the callback deletes the old Tcl command. self.fc.configure(command=lambda font: None) self.assertNotEqual(self.fc._command_name, name)