From 90f1a89ee0213107122f476cf1890493bf0d291e Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 15 Sep 2026 23:14:08 +0300 Subject: [PATCH 1/2] gh-75234: Fix keyboard selection in the lists of IDLE Settings The Up and Down keys move the selection in the help sources and key bindings lists, but not the anchor. So the buttons that act on the selected item stayed disabled, and acted on the anchored item instead of the selected one. Move the anchor on the key events, as the font list already does. Co-Authored-By: Claude Opus 5 (1M context) --- Lib/idlelib/configdialog.py | 29 ++++++++++++++--- Lib/idlelib/idle_test/test_configdialog.py | 32 ++++++++++++++++++- ...6-09-15-20-14-08.gh-issue-75234.O8ZNFV.rst | 2 ++ 3 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/IDLE/2026-09-15-20-14-08.gh-issue-75234.O8ZNFV.rst diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index 4c94d9be69e95e1..3964226b55c68b9 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -1146,8 +1146,8 @@ def create_page_keys(self): selected keyset. The keybindings are loaded in load_keys_list() and are pairs of (event, [keys]) where keys can be a list of one or more key combinations to bind to the same event. - Mouse button 1 click invokes on_bindingslist_select(), which - allows button_new_keys to be clicked. + Mouse button 1 click or Up or Down key invokes + on_bindingslist_select(), which allows button_new_keys to be clicked. So, an item is selected in listbindings, which activates button_new_keys, and clicking button_new_keys calls function @@ -1221,9 +1221,12 @@ def create_page_keys(self): scroll_target_y = Scrollbar(frame_target) scroll_target_x = Scrollbar(frame_target, orient=HORIZONTAL) self.bindingslist = Listbox( - frame_target, takefocus=FALSE, exportselection=FALSE) + frame_target, takefocus=True, exportselection=FALSE) self.bindingslist.bind('', self.on_bindingslist_select) + self.bindingslist.bind('', self.on_bindingslist_select) + self.bindingslist.bind('', + self.on_bindingslist_select) scroll_target_y['command'] = self.bindingslist.yview scroll_target_x['command'] = self.bindingslist.xview self.bindingslist['yscrollcommand'] = scroll_target_y.set @@ -1427,7 +1430,14 @@ def save_as_new_key_set(self): self.create_new_key_set(new_keys_name) def on_bindingslist_select(self, event): - "Activate button to assign new keys to selected action." + """Activate button to assign new keys to selected action. + + Event can result from either mouse click or Up or Down key. + The keys move the selection, but not the anchor used by + get_new_keys and var_changed_keybinding. + """ + if event.type.name == 'KeyRelease': + self.bindingslist.selection_anchor(ACTIVE) self.button_new_keys.state(('!disabled',)) def create_new_key_set(self, new_key_set_name): @@ -2124,6 +2134,8 @@ def create_frame_help(self): scroll_helplist['command'] = self.helplist.yview self.helplist['yscrollcommand'] = scroll_helplist.set self.helplist.bind('', self.help_source_selected) + self.helplist.bind('', self.help_source_selected) + self.helplist.bind('', self.help_source_selected) frame_buttons = Frame(self) self.button_helplist_edit = Button( @@ -2146,7 +2158,14 @@ def create_frame_help(self): self.button_helplist_remove.pack(side=TOP, anchor=W, pady=5) def help_source_selected(self, event): - "Handle event for selecting additional help." + """Handle event for selecting additional help. + + Event can result from either mouse click or Up or Down key. + The keys move the selection, but not the anchor used by + helplist_item_edit and helplist_item_remove. + """ + if event.type.name == 'KeyRelease': + self.helplist.selection_anchor(ACTIVE) self.set_add_delete_state() def set_add_delete_state(self): diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py index 3c5f99f98f0bc2d..3d5d9637cb405e7 100644 --- a/Lib/idlelib/idle_test/test_configdialog.py +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -9,7 +9,9 @@ import unittest from unittest import mock from idlelib.idle_test.mock_idle import Func -from tkinter import (Tk, StringVar, IntVar, BooleanVar, DISABLED, NORMAL) +from tkinter import (Tk, StringVar, IntVar, BooleanVar, DISABLED, NORMAL, + EventType) +from types import SimpleNamespace from idlelib import config from idlelib.configdialog import idleConf, changes, tracers @@ -1060,6 +1062,14 @@ def test_on_bindingslist_select(self): self.assertEqual(b.get('anchor'), 'find') self.assertNotIn('disabled', d.button_new_keys.state()) + # gh-75234: Up and Down keys move the active item, but not the + # anchor; the handler moves the anchor. + d.button_new_keys.state(('disabled',)) + b.activate(0) + d.on_bindingslist_select(SimpleNamespace(type=EventType.KeyRelease)) + self.assertEqual(b.get('anchor'), 'copy') + self.assertNotIn('disabled', d.button_new_keys.state()) + def test_create_new_key_set_and_save_new_key_set(self): eq = self.assertEqual d = self.page @@ -1584,6 +1594,26 @@ def test_helplist_item_remove(self): eq(fr.user_helplist, []) self.assertTrue(fr.upc.called == fr.set.called == 1) + def test_helplist_item_remove_keyboard_selection(self): + # gh-75234: Up and Down keys move the active item, but not the + # anchor; the handler moves the anchor. + eq = self.assertEqual + fr = self.frame + fr.helplist.delete(0, 'end') + fr.helplist.insert('end', 'name1', 'name2') + fr.helplist.selection_anchor(0) + fr.helplist.selection_set(1) + fr.helplist.activate(1) + fr.user_helplist.clear() + fr.user_helplist.extend([('name1', 'file1'), ('name2', 'file2')]) + fr.set.called = fr.upc.called = 0 + + fr.help_source_selected(SimpleNamespace(type=EventType.KeyRelease)) + eq(fr.helplist.get('anchor'), 'name2') + fr.helplist_item_remove() + eq(fr.helplist.get(0, 'end'), ('name1',)) + eq(fr.user_helplist, [('name1', 'file1')]) + def test_update_help_changes(self): fr = self.frame self.addCleanup(setattr, fr, 'update_help_changes', Func()) # Re-mask method. diff --git a/Misc/NEWS.d/next/IDLE/2026-09-15-20-14-08.gh-issue-75234.O8ZNFV.rst b/Misc/NEWS.d/next/IDLE/2026-09-15-20-14-08.gh-issue-75234.O8ZNFV.rst new file mode 100644 index 000000000000000..70d64aa09a6dee4 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-15-20-14-08.gh-issue-75234.O8ZNFV.rst @@ -0,0 +1,2 @@ +Fix editing help sources and key bindings in the IDLE Settings dialog after +selecting them with the keyboard. From 953439b4decf76a293e9b0b7ea9826216613c668 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Mon, 21 Sep 2026 12:53:39 +0300 Subject: [PATCH 2/2] Select the first key binding when the list is loaded --- Lib/idlelib/configdialog.py | 12 ++++++------ Lib/idlelib/idle_test/test_configdialog.py | 9 ++++++--- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py index 3964226b55c68b9..a5d1aaeb5a946f4 100644 --- a/Lib/idlelib/configdialog.py +++ b/Lib/idlelib/configdialog.py @@ -1475,9 +1475,8 @@ def load_keys_list(self, keyset_name): An action/key binding can be selected to change the key binding. """ - reselect = False + list_index = 0 if self.bindingslist.curselection(): - reselect = True list_index = self.bindingslist.index(ANCHOR) keyset = idleConf.GetKeySet(keyset_name) # 'set' is dict mapping virtual event to list of key events. @@ -1492,10 +1491,11 @@ def load_keys_list(self, keyset_name): if bind_name in changes['keys'][keyset_name]: key = changes['keys'][keyset_name][bind_name] self.bindingslist.insert(END, bind_name+' - '+key) - if reselect: - self.bindingslist.see(list_index) - self.bindingslist.select_set(list_index) - self.bindingslist.select_anchor(list_index) + self.bindingslist.see(list_index) + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + self.bindingslist.activate(list_index) + self.button_new_keys.state(('!disabled',)) @staticmethod def save_new_key_set(keyset_name, keyset): diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py index 3d5d9637cb405e7..367c63dc01126a3 100644 --- a/Lib/idlelib/idle_test/test_configdialog.py +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -1120,11 +1120,14 @@ def test_load_keys_list(self): 'force-open-completions - ', 'spam - ') - # No current selection. + # No current selection: select the first item. + d.button_new_keys.state(('disabled',)) d.load_keys_list('my keys') eq(b.get(0, 'end'), expected) - eq(b.get('anchor'), '') - eq(b.curselection(), ()) + eq(b.get('anchor'), 'copy - ') + eq(b.curselection(), (0, )) + eq(b.index('active'), 0) + self.assertNotIn('disabled', d.button_new_keys.state()) # Check selection. b.selection_set(1)