]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-72880: Add tkinter.fontchooser -- interface to the font selection dialog (GH-153255)
authorSerhiy Storchaka <storchaka@gmail.com>
Sat, 11 Jul 2026 05:50:08 +0000 (08:50 +0300)
committerGitHub <noreply@github.com>
Sat, 11 Jul 2026 05:50:08 +0000 (05:50 +0000)
The FontChooser class wraps the "tk fontchooser" command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Doc/library/tk.rst
Doc/library/tkinter.fontchooser.rst [new file with mode: 0644]
Doc/library/tkinter.rst
Doc/whatsnew/3.16.rst
Lib/test/test_tkinter/test_fontchooser.py [new file with mode: 0644]
Lib/tkinter/fontchooser.py [new file with mode: 0644]
Misc/NEWS.d/next/Library/2026-07-06-13-42-17.gh-issue-72880.fcXhWr.rst [new file with mode: 0644]

index c835f2518f7bf63584113d0bee31dbc39c6bbcad..e27af48ba7ac4857e7eb980697326bb2d7025958 100644 (file)
@@ -33,6 +33,7 @@ alternative `GUI frameworks and tools <https://wiki.python.org/moin/GuiProgrammi
    tkinter.rst
    tkinter.colorchooser.rst
    tkinter.font.rst
+   tkinter.fontchooser.rst
    dialog.rst
    tkinter.messagebox.rst
    tkinter.scrolledtext.rst
diff --git a/Doc/library/tkinter.fontchooser.rst b/Doc/library/tkinter.fontchooser.rst
new file mode 100644 (file)
index 0000000..969495f
--- /dev/null
@@ -0,0 +1,90 @@
+:mod:`!tkinter.fontchooser` --- Font selection dialog
+=====================================================
+
+.. module:: tkinter.fontchooser
+   :synopsis: Font selection dialog
+
+.. versionadded:: next
+
+**Source code:** :source:`Lib/tkinter/fontchooser.py`
+
+--------------
+
+The :mod:`!tkinter.fontchooser` module provides the :class:`FontChooser` class
+as an interface to the native font selection dialog.
+
+The font dialog is application-global:
+there is a single font dialog per Tcl interpreter,
+and all :class:`FontChooser` instances configure the same dialog.
+
+Depending on the platform, the dialog may be modal or modeless,
+so :meth:`~FontChooser.show` may return immediately.
+The selected font is not returned:
+it is passed as a :class:`~tkinter.font.Font` object to the callback
+specified with the *command* option.
+
+The dialog also generates two virtual events on the parent window
+(see the *parent* option):
+
+``<<TkFontchooserVisibility>>``
+   Generated when the dialog is shown or hidden.
+   Query the *visible* option to tell which.
+
+``<<TkFontchooserFontChanged>>``
+   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, **options)
+
+   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
index e989385aa802759161f9d78204725ba6bd52741f..6d62385f40bf483dc3a1295572142b6cf7e9c7b7 100644 (file)
@@ -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.
 
index 48573eff9827fea56049f4eda570564d793686b7..3b614d69de26f0f962b50f3966bbb7c1e9650b8f 100644 (file)
@@ -478,6 +478,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`.)
+
 * Values of several Tcl object types returned by :mod:`tkinter` are now
   converted to the corresponding Python type instead of being wrapped in a
   :class:`!_tkinter.Tcl_Obj`: ``index``, ``window``, ``nsName`` and
diff --git a/Lib/test/test_tkinter/test_fontchooser.py b/Lib/test/test_tkinter/test_fontchooser.py
new file mode 100644 (file)
index 0000000..5136889
--- /dev/null
@@ -0,0 +1,145 @@
+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().  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(actual(fc.cget('font')), f.actual())
+        f2 = font.Font(self.root, family='Times', size=11)
+        fc.configure(font=f2)
+        self.assertEqual(actual(fc.cget('font')), f2.actual())
+
+    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(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)
+        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('<<TkFontchooserVisibility>>', 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 (file)
index 0000000..705558d
--- /dev/null
@@ -0,0 +1,108 @@
+"""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, **options):
+        if master is None:
+            master = options.get('parent')
+        if master is None:
+            master = tkinter._get_default_root('create a font chooser')
+        self.master = master
+        self._command_name = None
+        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 (file)
index 0000000..688464c
--- /dev/null
@@ -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.