From 17625c0f94595e1b4cdca8204caaf39526bfc1c2 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 22 Sep 2026 00:00:06 +0300 Subject: [PATCH] gh-75234: Fix keyboard selection in the lists of IDLE Settings (GH-157588) 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) * Select the first key binding when the list is loaded --------- (cherry picked from commit 212e6035133957a66f1a823e012b4dc2a5158ce8) Co-authored-by: Serhiy Storchaka Co-authored-by: Claude Opus 5 (1M context) --- Lib/idlelib/configdialog.py | 41 ++++++++++++++----- Lib/idlelib/idle_test/test_configdialog.py | 41 +++++++++++++++++-- ...6-09-15-20-14-08.gh-issue-75234.O8ZNFV.rst | 2 + 3 files changed, 69 insertions(+), 15 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..a5d1aaeb5a946f4 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): @@ -1465,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. @@ -1482,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): @@ -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 21bdf36b52f451f..12927bee8d31fb6 100644 --- a/Lib/idlelib/idle_test/test_configdialog.py +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -10,7 +10,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 @@ -1061,6 +1063,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 @@ -1111,11 +1121,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) @@ -1585,6 +1598,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.