From 6acf212703f7feb18c8c0d1c58d793140c5f1794 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 6 Sep 2026 10:04:50 +0300 Subject: [PATCH] gh-75733: Show IDLE's trees in a ttk.Treeview idlelib.tree drew its rows on a canvas, with hardcoded pixel sizes that fit neither every font nor every monitor, an icon on every row, and bindings of its own for clicks and scrolling. It now wraps a ttk.Treeview, which takes its row height from the configured font and its indicators, colors and keys from ttk and the theme. Converted: the Module and Path browsers, Debug => Stack Viewer, the object browsers, and the stack, Locals and Globals panes of the Debug Control window. Rows carry more than a text now: an item fills the columns of its row with GetValues, and says with GetTags what kind of row it is, which is how the stack views show a frame as module, function, line and source, and how the Path Browser tells a directory, a package and a module apart. The stack pane of the debugger marks the frame it stopped in with a drawn arrow instead of a "> " in front of the text. The Locals and Globals panes are read-only tables, as editing them never reached the objects in the user process (gh-69184). The Module Browser shows no icons, its text saying already whether a row is a class or a function (gh-69277). Co-Authored-By: Claude Opus 5 --- Lib/idlelib/Icons/README.txt | 4 +- Lib/idlelib/Icons/minusnode.gif | Bin 75 -> 0 bytes Lib/idlelib/Icons/plusnode.gif | Bin 78 -> 0 bytes Lib/idlelib/browser.py | 45 +- Lib/idlelib/debugger.py | 230 ++++--- Lib/idlelib/debugobj.py | 23 +- Lib/idlelib/idle_test/htest.py | 6 +- Lib/idlelib/idle_test/test_browser.py | 22 +- Lib/idlelib/idle_test/test_debugger.py | 86 ++- Lib/idlelib/idle_test/test_pathbrowser.py | 26 +- Lib/idlelib/idle_test/test_stackviewer.py | 14 +- Lib/idlelib/idle_test/test_tree.py | 221 ++++++- Lib/idlelib/pathbrowser.py | 24 +- Lib/idlelib/pyshell.py | 12 +- Lib/idlelib/stackviewer.py | 38 +- Lib/idlelib/tree.py | 610 ++++++------------ ...6-09-05-19-00-00.gh-issue-75733.tRvW7q.rst | 8 + ...6-09-05-19-00-01.gh-issue-69184.Wn4pLs.rst | 3 + ...6-09-05-19-00-02.gh-issue-69277.mR7vQd.rst | 4 + 19 files changed, 773 insertions(+), 603 deletions(-) delete mode 100644 Lib/idlelib/Icons/minusnode.gif delete mode 100644 Lib/idlelib/Icons/plusnode.gif create mode 100644 Misc/NEWS.d/next/IDLE/2026-09-05-19-00-00.gh-issue-75733.tRvW7q.rst create mode 100644 Misc/NEWS.d/next/IDLE/2026-09-05-19-00-01.gh-issue-69184.Wn4pLs.rst create mode 100644 Misc/NEWS.d/next/IDLE/2026-09-05-19-00-02.gh-issue-69277.mR7vQd.rst diff --git a/Lib/idlelib/Icons/README.txt b/Lib/idlelib/Icons/README.txt index e245bc0b26e121e..19474fd9ce1bfe4 100644 --- a/Lib/idlelib/Icons/README.txt +++ b/Lib/idlelib/Icons/README.txt @@ -44,8 +44,8 @@ https://www.python.org/psf/trademarks-faq/ https://www.python.org/psf/trademarks/ # Usage. -OTHER GIFS: These are used by browsers using idlelib.tree. -At least some will not be used when tree is replaced by ttk.Treeview. +OTHER GIFS: folder, openfolder and python mark the directories, the +packages and the modules of the Path Browser. Edited 2024 August 26 by TJR. diff --git a/Lib/idlelib/Icons/minusnode.gif b/Lib/idlelib/Icons/minusnode.gif deleted file mode 100644 index 173e97095913ae40d771c3eef47328fca8cdf84e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75 zcmZ?wbhEHbR diff --git a/Lib/idlelib/browser.py b/Lib/idlelib/browser.py index 8b9060e57072ea0..e983cfc63ee1e7c 100644 --- a/Lib/idlelib/browser.py +++ b/Lib/idlelib/browser.py @@ -12,9 +12,8 @@ import pyclbr import sys -from idlelib.config import idleConf from idlelib import pyshell -from idlelib.tree import TreeNode, TreeItem, ScrolledCanvas +from idlelib.tree import TreeItem, TreeWidget from idlelib.util import py_extensions from idlelib.window import ListedToplevel @@ -96,9 +95,8 @@ def __init__(self, master, path, *, _htest=False, _utest=False): self.init() def close(self, event=None): - "Dismiss the window and the tree nodes." + "Dismiss the window." self.top.destroy() - self.node.destroy() def init(self): "Create browser tkinter widgets, including the tree." @@ -117,25 +115,23 @@ def init(self): top.geometry("+%d+%d" % (root.winfo_rootx(), root.winfo_rooty() + 200)) self.settitle() - top.focus_set() - - # create scrolled canvas - theme = idleConf.CurrentTheme() - background = idleConf.GetHighlight(theme, 'normal')['background'] - sc = ScrolledCanvas(top, bg=background, highlightthickness=0, - takefocus=1) - sc.frame.pack(expand=1, fill="both") - item = self.rootnode() - self.node = node = TreeNode(sc.canvas, None, item) + + # create the tree + self.tree = tree = TreeWidget(top, self.rootnode()) + tree.pack(expand=True, fill="both") + self.set_icons() + tree.focus_set() if not self._utest: - node.update() - node.expand() + tree.expand() def settitle(self): "Set the window title." self.top.wm_title("Module Browser - " + os.path.basename(self.path)) self.top.wm_iconname("Module Browser") + def set_icons(self): + "Give the rows icons. The module browser shows none; see gh-69277." + def rootnode(self): "Return a ModuleBrowserTreeItem as the root of the tree." return ModuleBrowserTreeItem(self.path) @@ -160,14 +156,14 @@ def GetText(self): "Return the module name as the text string to display." return os.path.basename(self.file) - def GetIconName(self): - "Return the name of the icon to display." - return "python" - def GetSubList(self): "Return ChildBrowserTreeItems for children." return [ChildBrowserTreeItem(obj) for obj in self.listchildren()] + def GetTags(self): + "Mark the row as a module, which the Path Browser gives an icon." + return ('module',) + def OnDoubleClick(self): "Open a module in an editor window when double clicked." if not is_browseable_extension(self.file): @@ -213,13 +209,6 @@ def GetText(self): else: return "class " + name - def GetIconName(self): - "Return the name of the icon to display." - if self.isfunction: - return "python" - else: - return "folder" - def IsExpandable(self): "Return True if self.obj has nested objects." return self.obj.children != {} @@ -244,7 +233,7 @@ def _module_browser(parent): # htest # else: file = __file__ # Add nested objects for htest. - class Nested_in_func(TreeNode): + class Nested_in_func(TreeItem): def nested_in_class(): pass def closure(): class Nested_in_closure: pass diff --git a/Lib/idlelib/debugger.py b/Lib/idlelib/debugger.py index 1fae1d4b0adbd75..53fff2521a5039b 100644 --- a/Lib/idlelib/debugger.py +++ b/Lib/idlelib/debugger.py @@ -19,10 +19,12 @@ import os from tkinter import * -from tkinter.ttk import Frame, Scrollbar +from tkinter.font import Font +from tkinter.ttk import Frame from idlelib import macosx -from idlelib.scrolledlist import ScrolledList +from idlelib.config import idleConf +from idlelib.tree import ScrolledTreeview from idlelib.window import ListedToplevel @@ -421,76 +423,161 @@ def load_breakpoints(self): continue -class StackViewer(ScrolledList): +class StackViewer(ScrolledTreeview): "Code stack viewer for debugger GUI." + # Shown while there is no stack to show. + default = "(None)" + + # The row of the frame that the debugger stopped in, and the rest. + CURRENT = "current" + PLAIN = "plain" + def __init__(self, master, flist, gui): - if macosx.isAquaTk(): - # At least on with the stock AquaTk version on OSX 10.4 you'll - # get a shaking GUI that eventually kills IDLE if the width - # argument is specified. - ScrolledList.__init__(self, master) - else: - ScrolledList.__init__(self, master, width=80) + super().__init__(master, + columns=("module", "function", "line", "source"), + headings=("", "Module", "Function", "Line", + "Source")) + self.pack(expand=1, fill="both") # As the list did before. self.flist = flist self.gui = gui self.stack = [] + self.selected = None # Index of the entry whose frame is shown. + self.menu = None + self.tree.bind("<>", self.select_event) + self.tree.bind("", self.double_click_event, add="+") + self.tree.bind("", self.double_click_event) + if macosx.isAquaTk(): + self.tree.bind("", self.popup_event) + self.tree.bind("", self.popup_event) + else: + self.tree.bind("", self.popup_event) + + def configure_style(self): + """Mark the row of the current frame with an arrow, in bold. + + The arrow is drawn here, not read from a file, so that it takes + its size from the font and its color from the theme. Rows + without it get a transparent image, as ttk indents the text of a + row with an image and they would not line up otherwise. + """ + super().configure_style() + color = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'normal')['foreground'] + size = max(7, self.font.metrics("linespace") * 2 // 3) | 1 + self.arrow = PhotoImage(master=self, width=size, height=size) + for y in range(size): + half = min(y, size - 1 - y) # Widest in the middle row. + self.arrow.put(color, to=(1, y, half + 2, y + 1)) + self.blank = PhotoImage(master=self, width=size, height=size) + self.bold = Font(root=self, font=self.font) + self.bold.configure(weight="bold") + self.tree.tag_configure(self.CURRENT, font=self.bold, + image=self.arrow) + self.tree.tag_configure(self.PLAIN, image=self.blank) + # The tree column holds the arrow alone; the source takes what + # the names and the line numbers leave over. + ch = self.font.measure("n") # Names, mostly lowercase. + digit = self.font.measure("0") # Line numbers. + self.tree.column("#0", width=size + 6, stretch=False) + self.tree.column("module", width=ch * 12, stretch=False) + self.tree.column("function", width=ch * 12, stretch=False) + self.tree.column("line", width=digit * 4, stretch=False, anchor="e") + self.tree.column("source", stretch=True) + + def close(self): + self.destroy() + + def get(self, index): + "Return the module, function, line and source of the stack entry." + return self.tree.item(self.tree.get_children()[index], "values") def load_stack(self, stack, index=None): self.stack = stack self.clear() - for i in range(len(stack)): - frame, lineno = stack[i] + self.selected = None + if not stack: + self.add_row(values=(self.default, "", "", ""), + tags=(self.PLAIN,)) + for i, (frame, lineno) in enumerate(stack): try: modname = frame.f_globals["__name__"] except: modname = "?" code = frame.f_code - filename = code.co_filename - funcname = code.co_name import linecache - sourceline = linecache.getline(filename, lineno) - sourceline = sourceline.strip() - if funcname in ("?", "", None): - item = "%s, line %d: %s" % (modname, lineno, sourceline) - else: - item = "%s.%s(), line %d: %s" % (modname, funcname, - lineno, sourceline) - if i == index: - item = "> " + item - self.append(item) + sourceline = linecache.getline(code.co_filename, lineno).strip() + self.add_row(values=(modname, code.co_name, lineno, sourceline), + tags=(self.CURRENT if i == index else self.PLAIN,)) if index is not None: self.select(index) - def popup_event(self, event): - "Override base method." - if self.stack: - return ScrolledList.popup_event(self, event) + def select(self, index): + "Select the row of the stack entry, without telling the debugger." + self.selected = index + iid = self.tree.get_children()[index] + self.tree.focus_set() + self.tree.focus(iid) + self.tree.selection_set(iid) + self.tree.see(iid) + + def index(self): + "Return the index of the selected stack entry, or None." + selection = self.tree.selection() + if selection: + index = self.tree.index(selection[0]) + if index < len(self.stack): + return index + return None + + def select_event(self, event=None): + """Show the frame of the row that the user has selected. + + The <> event is queued, so it also arrives for + the selection that select() sets; showing only a frame that is + not shown already keeps that from calling the debugger back. + """ + index = self.index() + if index is None or index == self.selected: + return + self.selected = index + self.gui.show_frame(self.stack[index]) + + def double_click_event(self, event=None): + "Open the source of the current row." + index = self.index() + if index is not None: + self.show_source(index) + return "break" - def fill_menu(self): - "Override base method." - menu = self.menu + def popup_event(self, event): + "Pop up the menu for the row under the pointer." + if not self.stack: + return None + iid = self.tree.identify_row(event.y) + if not iid: + return None + self.select(self.tree.index(iid)) + if self.menu is None: + self.make_menu() + self.menu.tk_popup(event.x_root, event.y_root) + return "break" + + def make_menu(self): + self.menu = menu = Menu(self.tree, tearoff=0) menu.add_command(label="Go to source line", command=self.goto_source_line) menu.add_command(label="Show stack frame", command=self.show_stack_frame) - def on_select(self, index): - "Override base method." - if 0 <= index < len(self.stack): - self.gui.show_frame(self.stack[index]) - - def on_double(self, index): - "Override base method." - self.show_source(index) - def goto_source_line(self): - index = self.listbox.index("active") - self.show_source(index) + index = self.index() + if index is not None: + self.show_source(index) def show_stack_frame(self): - index = self.listbox.index("active") - if 0 <= index < len(self.stack): + index = self.index() + if index is not None: self.gui.show_frame(self.stack[index]) def show_source(self, index): @@ -508,11 +595,10 @@ def show_source(self, index): class NamespaceViewer: "Global/local namespace viewer for debugger GUI." + # The pane shows at most this many rows and scrolls beyond that. + maxrows = 15 + def __init__(self, master, title, odict=None): # XXX odict never passed. - width = 0 - height = 40 - if odict: - height = 20*len(odict) # XXX 20 == observed height of Entry widget self.master = master self.title = title import reprlib @@ -523,16 +609,12 @@ def __init__(self, master, title, odict=None): # XXX odict never passed. self.frame.pack(expand=1, fill="both") self.label = Label(frame, text=title, borderwidth=2, relief="groove") self.label.pack(fill="x") - self.vbar = vbar = Scrollbar(frame, name="vbar") - vbar.pack(side="right", fill="y") - self.canvas = canvas = Canvas(frame, - height=min(300, max(40, height)), - scrollregion=(0, 0, width, height)) - canvas.pack(side="left", fill="both", expand=1) - vbar["command"] = canvas.yview - canvas["yscrollcommand"] = vbar.set - self.subframe = subframe = Frame(canvas) - self.sfid = canvas.create_window(0, 0, window=subframe, anchor="nw") + # A table, not the entries of before: editing them never had any + # effect, as the objects live in the user process. + self.treeview = ScrolledTreeview(frame, columns=("name", "value"), + show="", + headings=("", "Name", "Value")) + self.treeview.pack(expand=1, fill="both") self.load_dict(odict) prev_odict = -1 # Needed for initial comparison below. @@ -540,14 +622,11 @@ def __init__(self, master, title, odict=None): # XXX odict never passed. def load_dict(self, odict, force=0, rpc_client=None): if odict is self.prev_odict and not force: return - subframe = self.subframe - frame = self.frame - for c in list(subframe.children.values()): - c.destroy() + self.treeview.clear() self.prev_odict = None if not odict: - l = Label(subframe, text="None") - l.grid(row=0, column=0) + self.treeview.add_row(values=("None", "")) + rows = 1 else: #names = sorted(dict) # @@ -563,7 +642,6 @@ def load_dict(self, odict, force=0, rpc_client=None): keys_list = odict.keys() names = sorted(keys_list) - row = 0 for name in names: value = odict[name] svalue = self.repr.repr(value) # repr(value) @@ -571,25 +649,11 @@ def load_dict(self, odict, force=0, rpc_client=None): # repr'd value sent across the RPC interface: if rpc_client: svalue = svalue[1:-1] - l = Label(subframe, text=name) - l.grid(row=row, column=0, sticky="nw") - l = Entry(subframe, width=0, borderwidth=0) - l.insert(0, svalue) - l.grid(row=row, column=1, sticky="nw") - row = row+1 + self.treeview.add_row(values=(name, svalue)) + rows = len(names) self.prev_odict = odict - # XXX Could we use a callback for the following? - subframe.update_idletasks() # Alas! - width = subframe.winfo_reqwidth() - height = subframe.winfo_reqheight() - canvas = self.canvas - self.canvas["scrollregion"] = (0, 0, width, height) - if height > 300: - canvas["height"] = 300 - frame.pack(expand=1) - else: - canvas["height"] = height - frame.pack(expand=0) + self.treeview.tree['height'] = min(rows, self.maxrows) + self.frame.pack(expand=rows > self.maxrows) def close(self): self.frame.destroy() diff --git a/Lib/idlelib/debugobj.py b/Lib/idlelib/debugobj.py index fb448ece2fa25e7..65e7585e07e7d18 100644 --- a/Lib/idlelib/debugobj.py +++ b/Lib/idlelib/debugobj.py @@ -12,7 +12,7 @@ # - for classes/modules, add "open source" to object browser from reprlib import Repr -from idlelib.tree import TreeItem, TreeNode, ScrolledCanvas +from idlelib.tree import TreeItem, TreeWidget myrepr = Repr() myrepr.maxstring = 100 @@ -27,9 +27,6 @@ def GetLabelText(self): return self.labeltext def GetText(self): return myrepr.repr(self.object) - def GetIconName(self): - if not self.IsExpandable(): - return "python" def IsEditable(self): return self.setfunction is not None def SetText(self, text): @@ -51,7 +48,7 @@ def GetSubList(self): except AttributeError: continue item = make_objecttreeitem( - str(key) + " =", + str(key), value, lambda value, key=key, object_=self.object: setattr(object_, key, value)) @@ -64,10 +61,10 @@ def IsExpandable(self): def GetSubList(self): sublist = ObjectTreeItem.GetSubList(self) if len(self.object.__bases__) == 1: - item = make_objecttreeitem("__bases__[0] =", + item = make_objecttreeitem("__bases__[0]", self.object.__bases__[0]) else: - item = make_objecttreeitem("__bases__ =", self.object.__bases__) + item = make_objecttreeitem("__bases__", self.object.__bases__) sublist.insert(0, item) return sublist @@ -89,7 +86,7 @@ def GetSubList(self): continue def setfunction(value, key=key, object_=self.object): object_[key] = value - item = make_objecttreeitem(f"{key!r}:", value, setfunction) + item = make_objecttreeitem(repr(key), value, setfunction) sublist.append(item) return sublist @@ -129,13 +126,11 @@ def _debug_object_browser(parent): # htest # top.title("Test debug object browser") x, y = map(int, parent.geometry().split('+')[1:]) top.geometry("+%d+%d" % (x + 100, y + 175)) - top.configure(bd=0, bg="yellow") top.focus_set() - sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1) - sc.frame.pack(expand=1, fill="both") - item = make_objecttreeitem("sys", sys) - node = TreeNode(sc.canvas, None, item) - node.update() + tree = TreeWidget(top, make_objecttreeitem("sys", sys), + columns=("value",), headings=("Name", "Value")) + tree.pack(expand=True, fill="both") + tree.expand() if __name__ == '__main__': diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py index 0bd0378fbfa07a9..00bd46de399690a 100644 --- a/Lib/idlelib/idle_test/htest.py +++ b/Lib/idlelib/idle_test/htest.py @@ -338,8 +338,10 @@ _tree_widget_spec = { 'file': 'tree', 'kwds': {}, - 'msg': "The canvas is scrollable.\n" - "Click on folders up to the lowest level." + 'msg': "The tree of the attributes of the sys module.\n" + "Open and close rows with the mouse and with the keyboard.\n" + "Names and values are shown in separate columns.\n" + "The tree scrolls if the text does not fit." } _undo_delegator_spec = { diff --git a/Lib/idlelib/idle_test/test_browser.py b/Lib/idlelib/idle_test/test_browser.py index 6cfea3888cd6a90..beb6893ef074f51 100644 --- a/Lib/idlelib/idle_test/test_browser.py +++ b/Lib/idlelib/idle_test/test_browser.py @@ -12,7 +12,7 @@ import pyclbr from tkinter import Tk -from idlelib.tree import TreeNode +from idlelib.tree import TreeWidget class ModuleBrowserTest(unittest.TestCase): @@ -36,7 +36,7 @@ def test_init(self): eq = self.assertEqual eq(mb.path, __file__) eq(pyclbr._modules, {}) - self.assertIsInstance(mb.node, TreeNode) + self.assertIsInstance(mb.tree, TreeWidget) self.assertIsNotNone(browser.file_open) def test_settitle(self): @@ -49,14 +49,18 @@ def test_rootnode(self): rn = mb.rootnode() self.assertIsInstance(rn, browser.ModuleBrowserTreeItem) + def test_root_row(self): + mb = self.mb + tree = mb.tree.tree + self.assertEqual(tree.item(mb.tree.root, 'text'), + os.path.basename(__file__)) + def test_close(self): mb = self.mb mb.top.destroy = Func() - mb.node.destroy = Func() mb.close() self.assertTrue(mb.top.destroy.called) - self.assertTrue(mb.node.destroy.called) - del mb.top.destroy, mb.node.destroy + del mb.top.destroy def test_is_browseable_extension(self): path = "/path/to/file" @@ -133,9 +137,6 @@ def test_init(self): def test_gettext(self): self.assertEqual(self.mbt.GetText(), fname) - def test_geticonname(self): - self.assertEqual(self.mbt.GetIconName(), 'python') - def test_isexpandable(self): self.assertTrue(self.mbt.IsExpandable()) @@ -197,10 +198,6 @@ def test_gettext(self): self.assertEqual(self.cbt_C1.GetText(), 'class C1()') self.assertEqual(self.cbt_f1.GetText(), 'def f1(...)') - def test_geticonname(self): - self.assertEqual(self.cbt_C1.GetIconName(), 'folder') - self.assertEqual(self.cbt_f1.GetIconName(), 'python') - def test_isexpandable(self): self.assertTrue(self.cbt_C1.IsExpandable()) self.assertTrue(self.cbt_f1.IsExpandable()) @@ -247,7 +244,6 @@ def test_nested(self): sublist = cb.GetSubList() queue.extend(sublist) self.assertIn(cb.name, cb.GetText()) - self.assertIn(cb.GetIconName(), ('python', 'folder')) self.assertIs(cb.IsExpandable(), sublist != []) actual_names.append(cb.name) self.assertEqual(actual_names, expected_names) diff --git a/Lib/idlelib/idle_test/test_debugger.py b/Lib/idlelib/idle_test/test_debugger.py index 9ca3b332648b311..a0fd07d719a00ea 100644 --- a/Lib/idlelib/idle_test/test_debugger.py +++ b/Lib/idlelib/idle_test/test_debugger.py @@ -248,6 +248,7 @@ def setUp(self): ] # Create a stackviewer and load the test stack. self.sv = debugger.StackViewer(self.root, None, None) + self.addCleanup(self.sv.close) self.sv.load_stack(self.stack) def test_init(self): @@ -260,10 +261,47 @@ def test_init(self): def test_load_stack(self): # Test the .load_stack() method against a fixed test stack. - # Check the test stack is assigned and the list contains the repr of them. + # A row holds the module, the function, the line and its source. self.assertEqual(self.sv.stack, self.stack) - self.assertTrue('?.(), line 1:' in self.sv.get(0)) - self.assertEqual(self.sv.get(1), '?.(), line 2: ') + self.assertEqual(self.sv.get(0), ('?', '', '1', '')) + self.assertEqual(self.sv.get(1), ('?', '', '2', '')) + + def test_load_stack_marks_the_current_frame(self): + self.sv.load_stack(self.stack, 1) + tree = self.sv.tree + rows = tree.get_children() + self.assertEqual(tree.item(rows[0], 'tags'), (self.sv.PLAIN,)) + self.assertEqual(tree.item(rows[1], 'tags'), (self.sv.CURRENT,)) + # The marker is a tag now, not a prefix on the module name. + self.assertFalse(self.sv.get(1)[0].startswith('> ')) + # Both tags carry an image, so that the texts line up. + self.assertTrue(tree.tag_configure(self.sv.PLAIN, 'image')) + self.assertTrue(tree.tag_configure(self.sv.CURRENT, 'image')) + self.assertEqual(self.sv.index(), 1) + + def test_load_empty_stack(self): + self.sv.load_stack([]) + self.assertEqual(self.sv.get(0), (self.sv.default, '', '', '')) + self.assertIsNone(self.sv.index()) + + def test_load_stack_does_not_show_the_frame(self): + # Only the user selecting a row tells the debugger to show a frame. + self.sv.gui = Mock() + self.sv.load_stack(self.stack, 1) + self.sv.gui.show_frame.assert_not_called() + + def test_select_shows_the_frame(self): + self.sv.gui = Mock() + self.sv.tree.selection_set(self.sv.tree.get_children()[1]) + # Tk queues the event that selecting a row sends; deliver it now. + self.sv.tree.event_generate('<>', when='now') + self.sv.gui.show_frame.assert_called_once_with(self.stack[1]) + + def test_show_stack_frame(self): + self.sv.gui = Mock() + self.sv.select(1) + self.sv.show_stack_frame() + self.sv.gui.show_frame.assert_called_once_with(self.stack[1]) def test_show_source(self): # Test the .show_source() method against a fixed test stack. @@ -289,8 +327,48 @@ def tearDownClass(cls): cls.root.destroy() del cls.root + def setUp(self): + self.nv = debugger.NamespaceViewer(self.root, 'Test') + self.addCleanup(self.nv.close) + + def rows(self): + "Return the (name, value) pairs shown." + tree = self.nv.treeview.tree + return [tree.item(iid, 'values') for iid in tree.get_children()] + def test_init(self): - debugger.NamespaceViewer(self.root, 'Test') + self.assertEqual(self.rows(), [('None', '')]) + + def test_load_dict(self): + self.nv.load_dict({'b': 2, 'a': 'spam'}) # Names are sorted. + self.assertEqual(self.rows(), [('a', "'spam'"), ('b', '2')]) + + def test_load_no_dict(self): + self.nv.load_dict({'a': 1}) + self.nv.load_dict(None) + self.assertEqual(self.rows(), [('None', '')]) + + def test_load_same_dict(self): + odict = {'a': 1} + self.nv.load_dict(odict) + odict['a'] = 2 + self.nv.load_dict(odict) # The same dict is not read again. + self.assertEqual(self.rows(), [('a', '1')]) + self.nv.load_dict(odict, force=1) + self.assertEqual(self.rows(), [('a', '2')]) + + def test_load_dict_from_the_subprocess(self): + # The values arrive as reprs; the quotes added by repr'ing them + # again are stripped. + self.nv.load_dict({'a': "'spam'"}, rpc_client=Mock()) + self.assertEqual(self.rows(), [('a', "'spam'")]) + + def test_height_is_limited(self): + self.nv.load_dict({'a': 1, 'b': 2}) + self.assertEqual(int(self.nv.treeview.tree['height']), 2) + self.nv.load_dict({'v%d' % i: i for i in range(40)}) + self.assertEqual(int(self.nv.treeview.tree['height']), + self.nv.maxrows) if __name__ == '__main__': diff --git a/Lib/idlelib/idle_test/test_pathbrowser.py b/Lib/idlelib/idle_test/test_pathbrowser.py index a198978d5c1ef72..7c95f3d51824d8f 100644 --- a/Lib/idlelib/idle_test/test_pathbrowser.py +++ b/Lib/idlelib/idle_test/test_pathbrowser.py @@ -12,7 +12,7 @@ from idlelib.idle_test.mock_idle import Func import idlelib # for __file__ from idlelib import browser -from idlelib.tree import TreeNode +from idlelib.tree import TreeWidget class PathBrowserTest(unittest.TestCase): @@ -36,7 +36,7 @@ def test_init(self): eq = self.assertEqual eq(pb.master, self.root) eq(pyclbr._modules, {}) - self.assertIsInstance(pb.node, TreeNode) + self.assertIsInstance(pb.tree, TreeWidget) self.assertIsNotNone(browser.file_open) def test_settitle(self): @@ -49,18 +49,34 @@ def test_rootnode(self): rn = pb.rootnode() self.assertIsInstance(rn, pathbrowser.PathBrowserTreeItem) + def test_root_row(self): + pb = self.pb + self.assertEqual(pb.tree.tree.item(pb.tree.root, 'text'), 'sys.path') + + def test_icons(self): + # Each kind of row that gets an icon has its tag configured. + tree = self.pb.tree.tree + for tag in pathbrowser.ICONS: + with self.subTest(tag=tag): + self.assertTrue(tree.tag_configure(tag, 'image')) + def test_close(self): pb = self.pb pb.top.destroy = Func() - pb.node.destroy = Func() pb.close() self.assertTrue(pb.top.destroy.called) - self.assertTrue(pb.node.destroy.called) - del pb.top.destroy, pb.node.destroy + del pb.top.destroy class DirBrowserTreeItemTest(unittest.TestCase): + def test_gettags(self): + eq = self.assertEqual + eq(pathbrowser.DirBrowserTreeItem('/tmp').GetTags(), ('directory',)) + eq(pathbrowser.DirBrowserTreeItem('/tmp/p', ['p']).GetTags(), + ('package',)) + eq(browser.ModuleBrowserTreeItem('spam.py').GetTags(), ('module',)) + def test_DirBrowserTreeItem(self): # Issue16226 - make sure that getting a sublist works d = pathbrowser.DirBrowserTreeItem('') diff --git a/Lib/idlelib/idle_test/test_stackviewer.py b/Lib/idlelib/idle_test/test_stackviewer.py index 341f158031d5f42..21a400956f713f5 100644 --- a/Lib/idlelib/idle_test/test_stackviewer.py +++ b/Lib/idlelib/idle_test/test_stackviewer.py @@ -5,7 +5,7 @@ from test.support import requires from tkinter import Tk -from idlelib.tree import TreeNode, ScrolledCanvas +from idlelib.tree import TreeWidget class StackBrowserTest(unittest.TestCase): @@ -30,13 +30,17 @@ def test_init(self): try: abc except NameError as exc: - sb = stackviewer.StackBrowser(self.root, exc) + stackviewer.StackBrowser(self.root, exc) + widget = stackviewer.tree isi = self.assertIsInstance - isi(stackviewer.sc, ScrolledCanvas) + isi(widget, TreeWidget) isi(stackviewer.item, stackviewer.StackTreeItem) - isi(stackviewer.node, TreeNode) - top = stackviewer.sc.frame.winfo_toplevel() + top = widget.winfo_toplevel() self.assertEqual(top.winfo_class(), 'Idle') + # The root row is the exception; its children are the frames. + self.assertEqual(widget.tree.item(widget.root, 'text'), + "NameError: name 'abc' is not defined") + self.assertEqual(len(widget.tree.get_children(widget.root)), 1) if __name__ == '__main__': diff --git a/Lib/idlelib/idle_test/test_tree.py b/Lib/idlelib/idle_test/test_tree.py index 9be9abee361f083..b5dd7821b0fc08d 100644 --- a/Lib/idlelib/idle_test/test_tree.py +++ b/Lib/idlelib/idle_test/test_tree.py @@ -1,13 +1,94 @@ -"Test tree. coverage 56%." +"Test tree, coverage 95%." from idlelib import tree +import tkinter.ttk import unittest +from types import SimpleNamespace from test.support import requires requires('gui') from tkinter import Tk -class TreeTest(unittest.TestCase): +class Item(tree.TreeItem): + "A tree item with fixed children, counting the calls that matter." + + def __init__(self, text, children=(), label=None): + self.text = text + self.children = children + self.label = label + self.sublists = 0 # Number of GetSubList calls. + self.clicks = 0 # Number of OnDoubleClick calls. + + def GetText(self): + return self.text + + def GetLabelText(self): + return self.label + + def IsExpandable(self): + return bool(self.children) + + def GetSubList(self): + self.sublists += 1 + return list(self.children) + + def OnDoubleClick(self): + self.clicks += 1 + + +def tree_of(*texts): + "Return an item with a child for each text." + return Item('root', [Item(text) for text in texts]) + + +class ScrolledTreeviewTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def widget(self, **kwargs): + "Return a ScrolledTreeview destroyed at the end of the test." + widget = tree.ScrolledTreeview(self.root, **kwargs) + self.addCleanup(widget.destroy) + return widget + + def test_add_row(self): + widget = self.widget(columns=('value',)) + iid = widget.add_row(text='spam', values=('eggs',)) + self.assertEqual(widget.tree.item(iid, 'text'), 'spam') + self.assertEqual(widget.tree.set(iid, 'value'), 'eggs') + self.assertEqual(widget.tree.get_children(), (iid,)) + + def test_clear(self): + widget = self.widget() + widget.add_row(text='spam and eggs') + widget.clear() + self.assertEqual(widget.tree.get_children(), ()) + + def test_headings_columns(self): + widget = self.widget(columns=('name', 'value'), show='headings') + self.assertFalse(widget.tree_column) + iid = widget.add_row(values=('spam', 'eggs')) + self.assertEqual(widget.tree.set(iid, 'name'), 'spam') + self.assertEqual(widget.tree.set(iid, 'value'), 'eggs') + + def test_headings(self): + widget = self.widget(columns=('value',), headings=('Name', 'Value')) + # Tk 9.1 gives index objects, not strings, for the show option. + self.assertEqual([str(what) for what in widget.tree['show']], + ['tree', 'headings']) + self.assertEqual(widget.tree.heading('#0', 'text'), 'Name') + self.assertEqual(widget.tree.heading('#1', 'text'), 'Value') + + +class TreeWidgetTest(unittest.TestCase): @classmethod def setUpClass(cls): @@ -19,14 +100,134 @@ def tearDownClass(cls): cls.root.destroy() del cls.root - def test_init(self): - # Start with code slightly adapted from htest. - sc = tree.ScrolledCanvas( - self.root, bg="white", highlightthickness=0, takefocus=1) - sc.frame.pack(expand=1, fill="both", side='left') - item = tree.FileTreeItem(tree.ICONDIR) - node = tree.TreeNode(sc.canvas, None, item) - node.expand() + def widget(self, item=None, **kwargs): + "Return a TreeWidget destroyed at the end of the test." + widget = tree.TreeWidget(self.root, item, **kwargs) + self.addCleanup(widget.destroy) + return widget + + def texts(self, widget, iid=''): + "Return the texts of the rows under iid." + return [widget.tree.item(child, 'text') + for child in widget.tree.get_children(iid)] + + def test_empty(self): + widget = self.widget() + self.assertEqual(widget.root, '') + self.assertEqual(widget.items, {}) + self.assertEqual(self.texts(widget), []) + + def test_root_item(self): + item = Item('spam') + widget = self.widget(item) + self.assertEqual(self.texts(widget), ['spam']) + self.assertIs(widget.items[widget.root], item) + + def test_set_root_replaces_tree(self): + widget = self.widget(tree_of('spam')) + widget.expand() + root = widget.set_root(Item('eggs')) + self.assertEqual(self.texts(widget), ['eggs']) + self.assertEqual(list(widget.items), [root]) + + def test_leaf_has_no_children(self): + widget = self.widget(Item('spam')) + self.assertEqual(widget.tree.get_children(widget.root), ()) + + def test_expandable_item_has_placeholder(self): + widget = self.widget(tree_of('spam')) + children = widget.tree.get_children(widget.root) + self.assertEqual(len(children), 1) + # The placeholder only shows the indicator; it has no item. + self.assertNotIn(children[0], widget.items) + + def test_expand_fills_in_children(self): + item = tree_of('spam', 'eggs') + widget = self.widget(item) + self.assertEqual(item.sublists, 0) # Not expanded, not asked. + widget.expand() + self.assertEqual(item.sublists, 1) + self.assertEqual(self.texts(widget, widget.root), ['spam', 'eggs']) + self.assertTrue(widget.tree.item(widget.root, 'open')) + widget.expand() # Filled in only once. + self.assertEqual(item.sublists, 1) + + def test_expand_row(self): + item = tree_of('spam') + widget = self.widget(Item('root', [item])) + widget.expand() + child = widget.tree.get_children(widget.root)[0] + widget.expand(child) + self.assertEqual(self.texts(widget, child), ['spam']) + + def test_expand_without_root(self): + widget = self.widget() + widget.expand() # No root row, nothing to expand. + self.assertEqual(self.texts(widget), []) + + def test_open_fills_in_children(self): + item = tree_of('spam') + widget = self.widget(item) + widget.tree.focus(widget.root) + widget.opened() # As <> does. + self.assertEqual(self.texts(widget, widget.root), ['spam']) + + def test_expand_item_without_children(self): + # IsExpandable may be mistaken; the row becomes a leaf. + item = Item('spam') + item.IsExpandable = lambda: True + widget = self.widget(item) + widget.expand() + self.assertEqual(widget.tree.get_children(widget.root), ()) + + def test_values_columns(self): + widget = self.widget(Item('42', label='x ='), columns=('value',)) + self.assertEqual(self.texts(widget), ['x =']) + self.assertEqual(widget.tree.set(widget.root, 'value'), '42') + + def test_values_column_without_label(self): + widget = self.widget(Item('spam'), columns=('value',)) + self.assertEqual(self.texts(widget), ['spam']) + self.assertEqual(widget.tree.set(widget.root, 'value'), '') + + def test_label_without_values_column(self): + # The label goes in front of the text, as it did on the canvas. + widget = self.widget(Item('42', label='x =')) + self.assertEqual(self.texts(widget), ['x = 42']) + + def test_item_without_text(self): + widget = self.widget(tree.TreeItem()) + self.assertEqual(self.texts(widget), ['']) + + def test_activated(self): + item = Item('spam') + widget = self.widget(item) + widget.tree.focus(widget.root) + widget.activated() + self.assertEqual(item.clicks, 1) + + def test_activated_without_row(self): + widget = self.widget(tree_of('spam')) + widget.tree.focus('') + widget.activated() # No current row, nothing to activate. + + def test_double_clicked(self): + item = Item('spam') + widget = self.widget(item) + root = widget.root + widget.tree.identify_row = lambda y: root if y else '' + widget.double_clicked(SimpleNamespace(y=10)) + self.assertEqual(item.clicks, 1) + widget.double_clicked(SimpleNamespace(y=0)) # Below the rows. + self.assertEqual(item.clicks, 1) + + def test_configure_style(self): + # The row height follows the font, which ttk would leave at 20. + widget = self.widget(tree_of('spam')) + style = tkinter.ttk.Style(widget) + height = style.lookup(tree.STYLE, 'rowheight') + self.assertGreaterEqual(int(height), + widget.font.metrics('linespace')) if __name__ == '__main__': diff --git a/Lib/idlelib/pathbrowser.py b/Lib/idlelib/pathbrowser.py index 48a77875ba5801c..4ed9750cce9f174 100644 --- a/Lib/idlelib/pathbrowser.py +++ b/Lib/idlelib/pathbrowser.py @@ -2,9 +2,14 @@ import os import sys +from tkinter import PhotoImage + from idlelib.browser import ModuleBrowser, ModuleBrowserTreeItem from idlelib.tree import TreeItem +# The icon that marks each kind of row, by the tag of its item. +ICONS = {'directory': 'folder', 'package': 'openfolder', 'module': 'python'} + class PathBrowser(ModuleBrowser): @@ -22,6 +27,16 @@ def settitle(self): self.top.wm_title("Path Browser") self.top.wm_iconname("Path Browser") + def set_icons(self): + "Mark directories, packages and modules with the icons in Icons." + icondir = os.path.join(os.path.dirname(__file__), "Icons") + self.icons = {} # An image lives only while it is referenced. + for tag, name in ICONS.items(): + image = PhotoImage(master=self.tree, + file=os.path.join(icondir, name + ".gif")) + self.icons[tag] = image + self.tree.tree.tag_configure(tag, image=image) + def rootnode(self): return PathBrowserTreeItem() @@ -46,10 +61,11 @@ def __init__(self, dir, packages=[]): self.packages = packages def GetText(self): - if not self.packages: - return self.dir - else: - return self.packages[-1] + ": package" + return self.dir if not self.packages else self.packages[-1] + + def GetTags(self): + "Mark the row as a package or a plain directory." + return ('package',) if self.packages else ('directory',) def GetSubList(self): try: diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py index ef3d014d936ce85..beba507e73b0074 100755 --- a/Lib/idlelib/pyshell.py +++ b/Lib/idlelib/pyshell.py @@ -655,14 +655,12 @@ def remote_stack_viewer(self): self.tkconsole.root.bell() return item = debugobj_r.StubObjectTreeItem(self.rpcclt, oid) - from idlelib.tree import ScrolledCanvas, TreeNode + from idlelib.tree import TreeWidget top = Toplevel(self.tkconsole.root, class_='Idle') - theme = idleConf.CurrentTheme() - background = idleConf.GetHighlight(theme, 'normal')['background'] - sc = ScrolledCanvas(top, bg=background, highlightthickness=0) - sc.frame.pack(expand=1, fill="both") - node = TreeNode(sc.canvas, None, item) - node.expand() + tree = TreeWidget(top, item, columns=("value",), + headings=("Name", "Value")) + tree.pack(expand=True, fill="both") + tree.expand() # XXX Should GC the remote tree when closing the window gid = 0 diff --git a/Lib/idlelib/stackviewer.py b/Lib/idlelib/stackviewer.py index 268e2f1daeb98cc..5f041a7adf00b6e 100644 --- a/Lib/idlelib/stackviewer.py +++ b/Lib/idlelib/stackviewer.py @@ -6,17 +6,19 @@ import tkinter as tk from idlelib.debugobj import ObjectTreeItem, make_objecttreeitem -from idlelib.tree import TreeNode, TreeItem, ScrolledCanvas +from idlelib.tree import TreeItem, TreeWidget def StackBrowser(root, exc, flist=None, top=None): - global sc, item, node # For testing. + global tree, item # For testing. if top is None: top = tk.Toplevel(root, class_='Idle') - sc = ScrolledCanvas(top, bg="white", highlightthickness=0) - sc.frame.pack(expand=1, fill="both") item = StackTreeItem(exc, flist) - node = TreeNode(sc.canvas, None, item) - node.expand() + tree = TreeWidget(top, item, columns=("line", "value"), + headings=("Name", "Line", "Value")) + tree.tree.column("line", width=tree.font.measure("0") * 4, + stretch=False, anchor="e") + tree.pack(expand=True, fill="both") + tree.expand() class StackTreeItem(TreeItem): @@ -52,23 +54,21 @@ def __init__(self, info, flist): self.info = info self.flist = flist - def GetText(self): + def GetLabelText(self): + "Return the module and the function of the frame." frame, lineno = self.info try: modname = frame.f_globals["__name__"] except: modname = "?" - code = frame.f_code - filename = code.co_filename - funcname = code.co_name - sourceline = linecache.getline(filename, lineno) - sourceline = sourceline.strip() - if funcname in ("?", "", None): - item = "%s, line %d: %s" % (modname, lineno, sourceline) - else: - item = "%s.%s(...), line %d: %s" % (modname, funcname, - lineno, sourceline) - return item + funcname = frame.f_code.co_name + return f"{modname}.{funcname}(...)" if funcname else modname + + def GetValues(self): + "Return the line number of the frame and the source of that line." + frame, lineno = self.info + sourceline = linecache.getline(frame.f_code.co_filename, lineno) + return lineno, sourceline.strip() def GetSubList(self): frame, lineno = self.info @@ -108,7 +108,7 @@ def GetSubList(self): continue def setfunction(value, key=key, object_=self.object): object_[key] = value - item = make_objecttreeitem(key + " =", value, setfunction) + item = make_objecttreeitem(key, value, setfunction) sublist.append(item) return sublist diff --git a/Lib/idlelib/tree.py b/Lib/idlelib/tree.py index cd32f04b7c34abe..d186682ef597885 100644 --- a/Lib/idlelib/tree.py +++ b/Lib/idlelib/tree.py @@ -1,314 +1,19 @@ -# XXX TO DO: -# - popup menu -# - support partial or total redisplay -# - key bindings (instead of quick-n-dirty bindings on Canvas): -# - up/down arrow keys to move focus around -# - ditto for page up/down, home/end -# - left/right arrows to expand/collapse & move out/in -# - more doc strings -# - add icons for "file", "module", "class", "method"; better "python" icon -# - callback for selection??? -# - multiple-item selection -# - tooltips -# - redo geometry without magic numbers -# - keep track of object ids to allow more careful cleaning -# - optimize tree redraw after expand of subnode - -import os - -from tkinter import * -from tkinter.ttk import Frame, Scrollbar +"""A tree widget for IDLE, based on ttk.Treeview. + +TreeWidget shows a tree of TreeItems (see below). An item is asked for +its children only when its row is opened, so that a large tree costs +nothing until it is expanded. + +A Treeview sizes its rows after the font, gets its expand/collapse +indicators and colors from the theme, and navigates with the keyboard. +""" +from tkinter import ttk +from tkinter import font from idlelib.config import idleConf -from idlelib.util import bind_wheel, wheel_event -from idlelib import zoomheight - -ICONDIR = "Icons" - -# Look for Icons subdirectory in the same directory as this module -try: - _icondir = os.path.join(os.path.dirname(__file__), ICONDIR) -except NameError: - _icondir = ICONDIR -if os.path.isdir(_icondir): - ICONDIR = _icondir -elif not os.path.isdir(ICONDIR): - raise RuntimeError(f"can't find icon directory ({ICONDIR!r})") - -def listicons(icondir=ICONDIR): - """Utility to display the available icons.""" - root = Tk() - import glob - list = glob.glob(os.path.join(glob.escape(icondir), "*.gif")) - list.sort() - images = [] - row = column = 0 - for file in list: - name = os.path.splitext(os.path.basename(file))[0] - image = PhotoImage(file=file, master=root) - images.append(image) - label = Label(root, image=image, bd=1, relief="raised") - label.grid(row=row, column=column) - label = Label(root, text=name) - label.grid(row=row+1, column=column) - column = column + 1 - if column >= 10: - row = row+2 - column = 0 - root.images = images - - -class TreeNode: - - dy = 0 - - def __init__(self, canvas, parent, item): - self.canvas = canvas - self.parent = parent - self.item = item - self.state = 'collapsed' - self.selected = False - self.children = [] - self.x = self.y = None - self.iconimages = {} # cache of PhotoImage instances for icons - - def destroy(self): - for c in self.children[:]: - self.children.remove(c) - c.destroy() - self.parent = None - - def geticonimage(self, name): - try: - return self.iconimages[name] - except KeyError: - pass - file, ext = os.path.splitext(name) - ext = ext or ".gif" - fullname = os.path.join(ICONDIR, file + ext) - image = PhotoImage(master=self.canvas, file=fullname) - self.iconimages[name] = image - return image - - def select(self, event=None): - if self.selected: - return - self.deselectall() - self.selected = True - self.canvas.delete(self.image_id) - self.drawicon() - self.drawtext() - - def deselect(self, event=None): - if not self.selected: - return - self.selected = False - self.canvas.delete(self.image_id) - self.drawicon() - self.drawtext() - - def deselectall(self): - if self.parent: - self.parent.deselectall() - else: - self.deselecttree() - - def deselecttree(self): - if self.selected: - self.deselect() - for child in self.children: - child.deselecttree() - - def flip(self, event=None): - if self.state == 'expanded': - self.collapse() - else: - self.expand() - self.item.OnDoubleClick() - return "break" - - def expand(self, event=None): - if not self.item._IsExpandable(): - return - if self.state != 'expanded': - self.state = 'expanded' - self.update() - self.view() - - def collapse(self, event=None): - if self.state != 'collapsed': - self.state = 'collapsed' - self.update() - - def view(self): - top = self.y - 2 - bottom = self.lastvisiblechild().y + 17 - height = bottom - top - visible_top = self.canvas.canvasy(0) - visible_height = self.canvas.winfo_height() - visible_bottom = self.canvas.canvasy(visible_height) - if visible_top <= top and bottom <= visible_bottom: - return - x0, y0, x1, y1 = self.canvas._getints(self.canvas['scrollregion']) - if top >= visible_top and height <= visible_height: - fraction = top + height - visible_height - else: - fraction = top - fraction = float(fraction) / y1 - self.canvas.yview_moveto(fraction) - - def lastvisiblechild(self): - if self.children and self.state == 'expanded': - return self.children[-1].lastvisiblechild() - else: - return self - - def update(self): - if self.parent: - self.parent.update() - else: - oldcursor = self.canvas['cursor'] - self.canvas['cursor'] = "watch" - self.canvas.update() - self.canvas.delete(ALL) # XXX could be more subtle - self.draw(7, 2) - x0, y0, x1, y1 = self.canvas.bbox(ALL) - self.canvas.configure(scrollregion=(0, 0, x1, y1)) - self.canvas['cursor'] = oldcursor - - def draw(self, x, y): - # XXX This hard-codes too many geometry constants! - self.x, self.y = x, y - self.drawicon() - self.drawtext() - if self.state != 'expanded': - return y + TreeNode.dy - # draw children - if not self.children: - sublist = self.item._GetSubList() - if not sublist: - # _IsExpandable() was mistaken; that's allowed - return y + TreeNode.dy - for item in sublist: - child = self.__class__(self.canvas, self, item) - self.children.append(child) - cx = x+20 - cy = y + TreeNode.dy - cylast = 0 - for child in self.children: - cylast = cy - self.canvas.create_line(x+9, cy+7, cx, cy+7, fill="gray50") - cy = child.draw(cx, cy) - if child.item._IsExpandable(): - if child.state == 'expanded': - iconname = "minusnode" - callback = child.collapse - else: - iconname = "plusnode" - callback = child.expand - image = self.geticonimage(iconname) - id = self.canvas.create_image(x+9, cylast+7, image=image) - # XXX This leaks bindings until canvas is deleted: - self.canvas.tag_bind(id, "<1>", callback) - self.canvas.tag_bind(id, "", lambda x: None) - id = self.canvas.create_line(x+9, y+10, x+9, cylast+7, - ##stipple="gray50", # XXX Seems broken in Tk 8.0.x - fill="gray50") - self.canvas.tag_lower(id) # XXX .lower(id) before Python 1.5.2 - return cy - - def drawicon(self): - if self.selected: - imagename = (self.item.GetSelectedIconName() or - self.item.GetIconName() or - "openfolder") - else: - imagename = self.item.GetIconName() or "folder" - image = self.geticonimage(imagename) - id = self.canvas.create_image(self.x, self.y, anchor="nw", image=image) - self.image_id = id - self.canvas.tag_bind(id, "<1>", self.select) - self.canvas.tag_bind(id, "", self.flip) - - def drawtext(self): - textx = self.x+20-1 - texty = self.y-4 - labeltext = self.item.GetLabelText() - if labeltext: - id = self.canvas.create_text(textx, texty, anchor="nw", - text=labeltext) - self.canvas.tag_bind(id, "<1>", self.select) - self.canvas.tag_bind(id, "", self.flip) - x0, y0, x1, y1 = self.canvas.bbox(id) - textx = max(x1, 200) + 10 - text = self.item.GetText() or "" - try: - self.entry - except AttributeError: - pass - else: - self.edit_finish() - try: - self.label - except AttributeError: - # padding carefully selected (on Windows) to match Entry widget: - self.label = Label(self.canvas, text=text, bd=0, padx=2, pady=2) - theme = idleConf.CurrentTheme() - if self.selected: - self.label.configure(idleConf.GetHighlight(theme, 'hilite')) - else: - self.label.configure(idleConf.GetHighlight(theme, 'normal')) - id = self.canvas.create_window(textx, texty, - anchor="nw", window=self.label) - self.label.bind("<1>", self.select_or_edit) - self.label.bind("", self.flip) - bind_wheel(self.label, lambda e: wheel_event(e, self.canvas)) - self.text_id = id - if TreeNode.dy == 0: - # The first row doesn't matter what the dy is, just measure its - # size to get the value of the subsequent dy - coords = self.canvas.bbox(id) - TreeNode.dy = max(20, coords[3] - coords[1] - 3) - - def select_or_edit(self, event=None): - if self.selected and self.item.IsEditable(): - self.edit(event) - else: - self.select(event) - - def edit(self, event=None): - self.entry = Entry(self.label, bd=0, highlightthickness=1, width=0) - self.entry.insert(0, self.label['text']) - self.entry.selection_range(0, END) - self.entry.pack(ipadx=5) - self.entry.focus_set() - self.entry.bind("", self.edit_finish) - self.entry.bind("", self.edit_cancel) - - def edit_finish(self, event=None): - try: - entry = self.entry - del self.entry - except AttributeError: - return - text = entry.get() - entry.destroy() - if text and text != self.item.GetText(): - self.item.SetText(text) - text = self.item.GetText() - self.label['text'] = text - self.drawtext() - self.canvas.focus_set() - - def edit_cancel(self, event=None): - try: - entry = self.entry - del self.entry - except AttributeError: - return - entry.destroy() - self.drawtext() - self.canvas.focus_set() + +# The ttk style of the tree, colored after IDLE's current configuration. +STYLE = "IDLE.Treeview" class TreeItem: @@ -332,7 +37,7 @@ def GetLabelText(self): expandable = None def _IsExpandable(self): - """Do not override! Called by TreeNode.""" + """Do not override! Called by TreeWidget.""" if self.expandable is None: self.expandable = self.IsExpandable() return self.expandable @@ -342,7 +47,7 @@ def IsExpandable(self): return 1 def _GetSubList(self): - """Do not override! Called by TreeNode.""" + """Do not override! Called by TreeWidget.""" if not self.IsExpandable(): return [] sublist = self.GetSubList() @@ -356,120 +61,211 @@ def IsEditable(self): def SetText(self, text): """Change the item's text (if it is editable).""" - def GetIconName(self): - """Return name of icon to be displayed normally.""" - - def GetSelectedIconName(self): - """Return name of icon to be displayed when selected.""" - def GetSubList(self): """Return list of items forming sublist.""" - def OnDoubleClick(self): - """Called on a double-click on the item.""" - + def GetTags(self): + """Return the tags of the item's row, for its colors or its icon.""" -# Example application + def GetValues(self): + """Return the texts of the columns after the tree column. -class FileTreeItem(TreeItem): + The default is the text of the item in the last of them, which + is what a tree of names and values wants. + """ - """Example TreeItem subclass -- browse the file system.""" - - def __init__(self, path): - self.path = path + def OnDoubleClick(self): + """Called on a double-click on the item.""" - def GetText(self): - return os.path.basename(self.path) or self.path - def IsEditable(self): - return os.path.basename(self.path) != "" +class ScrolledTreeview(ttk.Frame): - def SetText(self, text): - newpath = os.path.dirname(self.path) - newpath = os.path.join(newpath, text) - if os.path.dirname(newpath) != os.path.dirname(self.path): - return - try: - os.rename(self.path, newpath) - self.path = newpath - except OSError: - pass - - def GetIconName(self): - if not self.IsExpandable(): - return "python" # XXX wish there was a "file" icon + """A ttk.Treeview with scrollbars, colored and sized after IDLE's config. - def IsExpandable(self): - return os.path.isdir(self.path) + The columns are left to ttk, which shares the width of the tree + between them and lets the user drag the edge of a heading. - def GetSubList(self): - try: - names = os.listdir(self.path) - except OSError: - return [] - names.sort(key = os.path.normcase) - sublist = [] - for name in names: - item = FileTreeItem(os.path.join(self.path, name)) - sublist.append(item) - return sublist + Attributes: + tree: The ttk.Treeview itself. + """ + def __init__(self, master, *, columns=(), show="tree", headings=(), **kw): + """Create the widget. + + master: The parent widget. + columns: The names of the columns after the tree column. + show: What the tree shows, as the ttk.Treeview option. + headings: A heading for each column, the tree column first. + Their edges can be dragged to widen a column. + Other keyword arguments are passed to ttk.Frame. + """ + super().__init__(master, **kw) + self.tree_column = "tree" in show + if headings: + show = (show + " headings").strip() + self.tree = ttk.Treeview(self, style=STYLE, show=show, + selectmode="browse", columns=columns) + for column, heading in enumerate(headings): + self.tree.heading("#%d" % column, text=heading, anchor="w") + vbar = ttk.Scrollbar(self, name="vbar", orient="vertical", + command=self.tree.yview) + hbar = ttk.Scrollbar(self, name="hbar", orient="horizontal", + command=self.tree.xview) + self.tree['yscrollcommand'] = vbar.set + self.tree['xscrollcommand'] = hbar.set + self.tree.grid(row=0, column=0, sticky="nsew") + vbar.grid(row=0, column=1, sticky="ns") + hbar.grid(row=1, column=0, sticky="ew") + self.rowconfigure(0, weight=1) + self.columnconfigure(0, weight=1) + self.configure_style() + + def focus_set(self): + "Focus the tree itself, so that the keys work at once." + self.tree.focus_set() + + def add_row(self, parent="", text="", values=(), **kw): + "Add a row at the end of the parent row and return its id." + return self.tree.insert(parent, "end", text=text, values=values, **kw) + + def clear(self): + "Remove all rows." + self.tree.delete(*self.tree.get_children()) + + def configure_style(self): + """Take the colors and the font of the tree from the configuration. + + The row height goes with the font, as the ttk default of 20 + pixels clips all but the smallest text. + """ + theme = idleConf.CurrentTheme() + normal = idleConf.GetHighlight(theme, 'normal') + hilite = idleConf.GetHighlight(theme, 'hilite') + text_font = idleConf.GetFont(self, 'main', 'EditorWindow') + self.font = font.Font(root=self, font=text_font) + style = ttk.Style(self) + style.configure(STYLE, font=text_font, + rowheight=self.font.metrics("linespace") + 2, + fieldbackground=normal['background'], **normal) + style.map(STYLE, + background=[('selected', hilite['background'])], + foreground=[('selected', hilite['foreground'])]) + + +class TreeWidget(ScrolledTreeview): + + """A scrolled tree of TreeItems. + + Create the widget, give it the item to show as the root of the tree, + and pack or grid it like any other widget: + + tree = TreeWidget(top, rootitem) + tree.pack(expand=True, fill="both") + + An item is asked for its children only when its row is opened. + Its label text goes in the tree column and its text in the last + column, or after the label where there is only the tree column. + See GetValues for filling more columns than one. + + Attributes: + tree: The ttk.Treeview showing the items. + items: Map of the row ids of the tree to their TreeItems. + root: The row id of the root item, or '' if there is none. + """ -# A canvas widget with scroll bars and some useful bindings - -class ScrolledCanvas: - - def __init__(self, master, **opts): - if 'yscrollincrement' not in opts: - opts['yscrollincrement'] = 17 - self.master = master - self.frame = Frame(master) - self.frame.rowconfigure(0, weight=1) - self.frame.columnconfigure(0, weight=1) - self.canvas = Canvas(self.frame, **opts) - self.canvas.grid(row=0, column=0, sticky="nsew") - self.vbar = Scrollbar(self.frame, name="vbar") - self.vbar.grid(row=0, column=1, sticky="nse") - self.hbar = Scrollbar(self.frame, name="hbar", orient="horizontal") - self.hbar.grid(row=1, column=0, sticky="ews") - self.canvas['yscrollcommand'] = self.vbar.set - self.vbar['command'] = self.canvas.yview - self.canvas['xscrollcommand'] = self.hbar.set - self.hbar['command'] = self.canvas.xview - self.canvas.bind("", self.page_up) - self.canvas.bind("", self.page_down) - self.canvas.bind("", self.unit_up) - self.canvas.bind("", self.unit_down) - bind_wheel(self.canvas, wheel_event) - #if isinstance(master, Toplevel) or isinstance(master, Tk): - self.canvas.bind("", self.zoom_height) - self.canvas.focus_set() - def page_up(self, event): - self.canvas.yview_scroll(-1, "page") - return "break" - def page_down(self, event): - self.canvas.yview_scroll(1, "page") - return "break" - def unit_up(self, event): - self.canvas.yview_scroll(-1, "unit") - return "break" - def unit_down(self, event): - self.canvas.yview_scroll(1, "unit") - return "break" - def zoom_height(self, event): - zoomheight.zoom_height(self.master) - return "break" + def __init__(self, master, item=None, **kw): + """Create the widget and, if item is given, show it as the root. + + master: The parent widget. + item: The TreeItem to show as the root of the tree. + Other keyword arguments are passed to ScrolledTreeview. + """ + super().__init__(master, **kw) + self.items = {} + self.root = '' + self.tree.bind("<>", self.opened) + # A double click also opens or closes the row, as it did before. + self.tree.bind("", self.double_clicked, add="+") + self.tree.bind("", self.activated) + if item is not None: + self.set_root(item) + + def set_root(self, item): + "Show item as the root of the tree, replacing what the tree shows." + self.clear() + self.items.clear() + self.root = self.add_item('', item) + return self.root + + def add_item(self, parent, item): + "Add a row for item as a child of the parent row and return its id." + columns = self.tree['columns'] + text = item.GetLabelText() + value = item.GetText() or '' + if not text: + text, value = value, '' + elif not columns: + text, value = f'{text} {value}', '' # No column for it. + values = item.GetValues() if columns else () + if values is None: + values = [''] * (len(columns) - 1) + [value] + values = (list(values) + [''] * len(columns))[:len(columns)] + iid = self.add_row(parent, text=text, values=values, + tags=item.GetTags() or ()) + self.items[iid] = item + if item._IsExpandable(): + # A placeholder gives the row its indicator; opening the row + # replaces it with the children. + self.tree.insert(iid, "end") + return iid + + def expand(self, iid=None): + "Open the given row, the root row by default, and fill it in." + if iid is None: + iid = self.root + if iid: + self.fill(iid) + self.tree.item(iid, open=True) + + def fill(self, iid): + "Replace the placeholder child of a row with rows for the children." + children = self.tree.get_children(iid) + if not children or children[0] in self.items: + return # A leaf, or already filled in. + self.tree.delete(*children) + for item in self.items[iid]._GetSubList(): + self.add_item(iid, item) + + def opened(self, event=None): + "Fill in the row that the user has just opened." + self.fill(self.tree.focus()) + + def activated(self, event=None): + "Call OnDoubleClick for the current row." + item = self.items.get(self.tree.focus()) + if item is not None: + item.OnDoubleClick() + + def double_clicked(self, event): + "Call OnDoubleClick for the double-clicked row." + item = self.items.get(self.tree.identify_row(event.y)) + if item is not None: + item.OnDoubleClick() def _tree_widget(parent): # htest # + from tkinter import Toplevel + from idlelib.debugobj import make_objecttreeitem + import sys + top = Toplevel(parent) + top.title("Test TreeWidget") x, y = map(int, parent.geometry().split('+')[1:]) - top.geometry("+%d+%d" % (x+50, y+175)) - sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1) - sc.frame.pack(expand=1, fill="both", side=LEFT) - item = FileTreeItem(ICONDIR) - node = TreeNode(sc.canvas, None, item) - node.expand() + top.geometry("+%d+%d" % (x + 50, y + 175)) + tree = TreeWidget(top, make_objecttreeitem("sys", sys), + columns=("value",), headings=("Name", "Value")) + tree.pack(expand=True, fill="both") + tree.expand() if __name__ == '__main__': diff --git a/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-00.gh-issue-75733.tRvW7q.rst b/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-00.gh-issue-75733.tRvW7q.rst new file mode 100644 index 000000000000000..25a04db0c0bcc7c --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-00.gh-issue-75733.tRvW7q.rst @@ -0,0 +1,8 @@ +IDLE's trees are now ``ttk.Treeview`` widgets instead of trees drawn on a +canvas: the Module and Path browsers, Debug => Stack Viewer, the object +browsers, and the stack and namespace panes of the Debug Control window. +Rows are as high as the configured font needs, so they are no longer clipped +on high-resolution monitors, and the trees are navigated with the keyboard. +The stack views show the module, the function, the line number and the +source of a frame in columns of their own, and the debugger marks the frame +that it stopped in with an arrow instead of a "> " in front of the text. diff --git a/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-01.gh-issue-69184.Wn4pLs.rst b/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-01.gh-issue-69184.Wn4pLs.rst new file mode 100644 index 000000000000000..81c8332268d513c --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-01.gh-issue-69184.Wn4pLs.rst @@ -0,0 +1,3 @@ +The Locals and Globals panes of IDLE's Debug Control window no longer show +values in entries that look editable. +Editing them never had any effect, as the objects live in the user process. diff --git a/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-02.gh-issue-69277.mR7vQd.rst b/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-02.gh-issue-69277.mR7vQd.rst new file mode 100644 index 000000000000000..b8f138285a00608 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-09-05-19-00-02.gh-issue-69277.mR7vQd.rst @@ -0,0 +1,4 @@ +IDLE's Module Browser no longer puts a Python or folder icon on every line; +the text of a row already says whether it is a class or a function. +The Path Browser keeps icons, which now tell a directory, a package and a +module apart, so a package row no longer says ": package".