1# XXX TO DO: 2# - popup menu 3# - support partial or total redisplay 4# - key bindings (instead of quick-n-dirty bindings on Canvas): 5# - up/down arrow keys to move focus around 6# - ditto for page up/down, home/end 7# - left/right arrows to expand/collapse & move out/in 8# - more doc strings 9# - add icons for "file", "module", "class", "method"; better "python" icon 10# - callback for selection??? 11# - multiple-item selection 12# - tooltips 13# - redo geometry without magic numbers 14# - keep track of object ids to allow more careful cleaning 15# - optimize tree redraw after expand of subnode 16 17import os 18 19from tkinter import * 20from tkinter.ttk import Frame, Scrollbar 21 22from idlelib.config import idleConf 23from idlelib import zoomheight 24 25ICONDIR = "Icons" 26 27# Look for Icons subdirectory in the same directory as this module 28try: 29 _icondir = os.path.join(os.path.dirname(__file__), ICONDIR) 30except NameError: 31 _icondir = ICONDIR 32if os.path.isdir(_icondir): 33 ICONDIR = _icondir 34elif not os.path.isdir(ICONDIR): 35 raise RuntimeError(f"can't find icon directory ({ICONDIR!r})") 36 37def listicons(icondir=ICONDIR): 38 """Utility to display the available icons.""" 39 root = Tk() 40 import glob 41 list = glob.glob(os.path.join(glob.escape(icondir), "*.gif")) 42 list.sort() 43 images = [] 44 row = column = 0 45 for file in list: 46 name = os.path.splitext(os.path.basename(file))[0] 47 image = PhotoImage(file=file, master=root) 48 images.append(image) 49 label = Label(root, image=image, bd=1, relief="raised") 50 label.grid(row=row, column=column) 51 label = Label(root, text=name) 52 label.grid(row=row+1, column=column) 53 column = column + 1 54 if column >= 10: 55 row = row+2 56 column = 0 57 root.images = images 58 59def wheel_event(event, widget=None): 60 """Handle scrollwheel event. 61 62 For wheel up, event.delta = 120*n on Windows, -1*n on darwin, 63 where n can be > 1 if one scrolls fast. Flicking the wheel 64 generates up to maybe 20 events with n up to 10 or more 1. 65 Macs use wheel down (delta = 1*n) to scroll up, so positive 66 delta means to scroll up on both systems. 67 68 X-11 sends Control-Button-4,5 events instead. 69 70 The widget parameter is needed so browser label bindings can pass 71 the underlying canvas. 72 73 This function depends on widget.yview to not be overridden by 74 a subclass. 75 """ 76 up = {EventType.MouseWheel: event.delta > 0, 77 EventType.ButtonPress: event.num == 4} 78 lines = -5 if up[event.type] else 5 79 widget = event.widget if widget is None else widget 80 widget.yview(SCROLL, lines, 'units') 81 return 'break' 82 83 84class TreeNode: 85 86 dy = 0 87 88 def __init__(self, canvas, parent, item): 89 self.canvas = canvas 90 self.parent = parent 91 self.item = item 92 self.state = 'collapsed' 93 self.selected = False 94 self.children = [] 95 self.x = self.y = None 96 self.iconimages = {} # cache of PhotoImage instances for icons 97 98 def destroy(self): 99 for c in self.children[:]: 100 self.children.remove(c) 101 c.destroy() 102 self.parent = None 103 104 def geticonimage(self, name): 105 try: 106 return self.iconimages[name] 107 except KeyError: 108 pass 109 file, ext = os.path.splitext(name) 110 ext = ext or ".gif" 111 fullname = os.path.join(ICONDIR, file + ext) 112 image = PhotoImage(master=self.canvas, file=fullname) 113 self.iconimages[name] = image 114 return image 115 116 def select(self, event=None): 117 if self.selected: 118 return 119 self.deselectall() 120 self.selected = True 121 self.canvas.delete(self.image_id) 122 self.drawicon() 123 self.drawtext() 124 125 def deselect(self, event=None): 126 if not self.selected: 127 return 128 self.selected = False 129 self.canvas.delete(self.image_id) 130 self.drawicon() 131 self.drawtext() 132 133 def deselectall(self): 134 if self.parent: 135 self.parent.deselectall() 136 else: 137 self.deselecttree() 138 139 def deselecttree(self): 140 if self.selected: 141 self.deselect() 142 for child in self.children: 143 child.deselecttree() 144 145 def flip(self, event=None): 146 if self.state == 'expanded': 147 self.collapse() 148 else: 149 self.expand() 150 self.item.OnDoubleClick() 151 return "break" 152 153 def expand(self, event=None): 154 if not self.item._IsExpandable(): 155 return 156 if self.state != 'expanded': 157 self.state = 'expanded' 158 self.update() 159 self.view() 160 161 def collapse(self, event=None): 162 if self.state != 'collapsed': 163 self.state = 'collapsed' 164 self.update() 165 166 def view(self): 167 top = self.y - 2 168 bottom = self.lastvisiblechild().y + 17 169 height = bottom - top 170 visible_top = self.canvas.canvasy(0) 171 visible_height = self.canvas.winfo_height() 172 visible_bottom = self.canvas.canvasy(visible_height) 173 if visible_top <= top and bottom <= visible_bottom: 174 return 175 x0, y0, x1, y1 = self.canvas._getints(self.canvas['scrollregion']) 176 if top >= visible_top and height <= visible_height: 177 fraction = top + height - visible_height 178 else: 179 fraction = top 180 fraction = float(fraction) / y1 181 self.canvas.yview_moveto(fraction) 182 183 def lastvisiblechild(self): 184 if self.children and self.state == 'expanded': 185 return self.children[-1].lastvisiblechild() 186 else: 187 return self 188 189 def update(self): 190 if self.parent: 191 self.parent.update() 192 else: 193 oldcursor = self.canvas['cursor'] 194 self.canvas['cursor'] = "watch" 195 self.canvas.update() 196 self.canvas.delete(ALL) # XXX could be more subtle 197 self.draw(7, 2) 198 x0, y0, x1, y1 = self.canvas.bbox(ALL) 199 self.canvas.configure(scrollregion=(0, 0, x1, y1)) 200 self.canvas['cursor'] = oldcursor 201 202 def draw(self, x, y): 203 # XXX This hard-codes too many geometry constants! 204 self.x, self.y = x, y 205 self.drawicon() 206 self.drawtext() 207 if self.state != 'expanded': 208 return y + TreeNode.dy 209 # draw children 210 if not self.children: 211 sublist = self.item._GetSubList() 212 if not sublist: 213 # _IsExpandable() was mistaken; that's allowed 214 return y + TreeNode.dy 215 for item in sublist: 216 child = self.__class__(self.canvas, self, item) 217 self.children.append(child) 218 cx = x+20 219 cy = y + TreeNode.dy 220 cylast = 0 221 for child in self.children: 222 cylast = cy 223 self.canvas.create_line(x+9, cy+7, cx, cy+7, fill="gray50") 224 cy = child.draw(cx, cy) 225 if child.item._IsExpandable(): 226 if child.state == 'expanded': 227 iconname = "minusnode" 228 callback = child.collapse 229 else: 230 iconname = "plusnode" 231 callback = child.expand 232 image = self.geticonimage(iconname) 233 id = self.canvas.create_image(x+9, cylast+7, image=image) 234 # XXX This leaks bindings until canvas is deleted: 235 self.canvas.tag_bind(id, "<1>", callback) 236 self.canvas.tag_bind(id, "<Double-1>", lambda x: None) 237 id = self.canvas.create_line(x+9, y+10, x+9, cylast+7, 238 ##stipple="gray50", # XXX Seems broken in Tk 8.0.x 239 fill="gray50") 240 self.canvas.tag_lower(id) # XXX .lower(id) before Python 1.5.2 241 return cy 242 243 def drawicon(self): 244 if self.selected: 245 imagename = (self.item.GetSelectedIconName() or 246 self.item.GetIconName() or 247 "openfolder") 248 else: 249 imagename = self.item.GetIconName() or "folder" 250 image = self.geticonimage(imagename) 251 id = self.canvas.create_image(self.x, self.y, anchor="nw", image=image) 252 self.image_id = id 253 self.canvas.tag_bind(id, "<1>", self.select) 254 self.canvas.tag_bind(id, "<Double-1>", self.flip) 255 256 def drawtext(self): 257 textx = self.x+20-1 258 texty = self.y-4 259 labeltext = self.item.GetLabelText() 260 if labeltext: 261 id = self.canvas.create_text(textx, texty, anchor="nw", 262 text=labeltext) 263 self.canvas.tag_bind(id, "<1>", self.select) 264 self.canvas.tag_bind(id, "<Double-1>", self.flip) 265 x0, y0, x1, y1 = self.canvas.bbox(id) 266 textx = max(x1, 200) + 10 267 text = self.item.GetText() or "<no text>" 268 try: 269 self.entry 270 except AttributeError: 271 pass 272 else: 273 self.edit_finish() 274 try: 275 self.label 276 except AttributeError: 277 # padding carefully selected (on Windows) to match Entry widget: 278 self.label = Label(self.canvas, text=text, bd=0, padx=2, pady=2) 279 theme = idleConf.CurrentTheme() 280 if self.selected: 281 self.label.configure(idleConf.GetHighlight(theme, 'hilite')) 282 else: 283 self.label.configure(idleConf.GetHighlight(theme, 'normal')) 284 id = self.canvas.create_window(textx, texty, 285 anchor="nw", window=self.label) 286 self.label.bind("<1>", self.select_or_edit) 287 self.label.bind("<Double-1>", self.flip) 288 self.label.bind("<MouseWheel>", lambda e: wheel_event(e, self.canvas)) 289 if self.label._windowingsystem == 'x11': 290 self.label.bind("<Button-4>", lambda e: wheel_event(e, self.canvas)) 291 self.label.bind("<Button-5>", lambda e: wheel_event(e, self.canvas)) 292 self.text_id = id 293 if TreeNode.dy == 0: 294 # The first row doesn't matter what the dy is, just measure its 295 # size to get the value of the subsequent dy 296 coords = self.canvas.bbox(id) 297 TreeNode.dy = max(20, coords[3] - coords[1] - 3) 298 299 def select_or_edit(self, event=None): 300 if self.selected and self.item.IsEditable(): 301 self.edit(event) 302 else: 303 self.select(event) 304 305 def edit(self, event=None): 306 self.entry = Entry(self.label, bd=0, highlightthickness=1, width=0) 307 self.entry.insert(0, self.label['text']) 308 self.entry.selection_range(0, END) 309 self.entry.pack(ipadx=5) 310 self.entry.focus_set() 311 self.entry.bind("<Return>", self.edit_finish) 312 self.entry.bind("<Escape>", self.edit_cancel) 313 314 def edit_finish(self, event=None): 315 try: 316 entry = self.entry 317 del self.entry 318 except AttributeError: 319 return 320 text = entry.get() 321 entry.destroy() 322 if text and text != self.item.GetText(): 323 self.item.SetText(text) 324 text = self.item.GetText() 325 self.label['text'] = text 326 self.drawtext() 327 self.canvas.focus_set() 328 329 def edit_cancel(self, event=None): 330 try: 331 entry = self.entry 332 del self.entry 333 except AttributeError: 334 return 335 entry.destroy() 336 self.drawtext() 337 self.canvas.focus_set() 338 339 340class TreeItem: 341 342 """Abstract class representing tree items. 343 344 Methods should typically be overridden, otherwise a default action 345 is used. 346 347 """ 348 349 def __init__(self): 350 """Constructor. Do whatever you need to do.""" 351 352 def GetText(self): 353 """Return text string to display.""" 354 355 def GetLabelText(self): 356 """Return label text string to display in front of text (if any).""" 357 358 expandable = None 359 360 def _IsExpandable(self): 361 """Do not override! Called by TreeNode.""" 362 if self.expandable is None: 363 self.expandable = self.IsExpandable() 364 return self.expandable 365 366 def IsExpandable(self): 367 """Return whether there are subitems.""" 368 return 1 369 370 def _GetSubList(self): 371 """Do not override! Called by TreeNode.""" 372 if not self.IsExpandable(): 373 return [] 374 sublist = self.GetSubList() 375 if not sublist: 376 self.expandable = 0 377 return sublist 378 379 def IsEditable(self): 380 """Return whether the item's text may be edited.""" 381 382 def SetText(self, text): 383 """Change the item's text (if it is editable).""" 384 385 def GetIconName(self): 386 """Return name of icon to be displayed normally.""" 387 388 def GetSelectedIconName(self): 389 """Return name of icon to be displayed when selected.""" 390 391 def GetSubList(self): 392 """Return list of items forming sublist.""" 393 394 def OnDoubleClick(self): 395 """Called on a double-click on the item.""" 396 397 398# Example application 399 400class FileTreeItem(TreeItem): 401 402 """Example TreeItem subclass -- browse the file system.""" 403 404 def __init__(self, path): 405 self.path = path 406 407 def GetText(self): 408 return os.path.basename(self.path) or self.path 409 410 def IsEditable(self): 411 return os.path.basename(self.path) != "" 412 413 def SetText(self, text): 414 newpath = os.path.dirname(self.path) 415 newpath = os.path.join(newpath, text) 416 if os.path.dirname(newpath) != os.path.dirname(self.path): 417 return 418 try: 419 os.rename(self.path, newpath) 420 self.path = newpath 421 except OSError: 422 pass 423 424 def GetIconName(self): 425 if not self.IsExpandable(): 426 return "python" # XXX wish there was a "file" icon 427 428 def IsExpandable(self): 429 return os.path.isdir(self.path) 430 431 def GetSubList(self): 432 try: 433 names = os.listdir(self.path) 434 except OSError: 435 return [] 436 names.sort(key = os.path.normcase) 437 sublist = [] 438 for name in names: 439 item = FileTreeItem(os.path.join(self.path, name)) 440 sublist.append(item) 441 return sublist 442 443 444# A canvas widget with scroll bars and some useful bindings 445 446class ScrolledCanvas: 447 448 def __init__(self, master, **opts): 449 if 'yscrollincrement' not in opts: 450 opts['yscrollincrement'] = 17 451 self.master = master 452 self.frame = Frame(master) 453 self.frame.rowconfigure(0, weight=1) 454 self.frame.columnconfigure(0, weight=1) 455 self.canvas = Canvas(self.frame, **opts) 456 self.canvas.grid(row=0, column=0, sticky="nsew") 457 self.vbar = Scrollbar(self.frame, name="vbar") 458 self.vbar.grid(row=0, column=1, sticky="nse") 459 self.hbar = Scrollbar(self.frame, name="hbar", orient="horizontal") 460 self.hbar.grid(row=1, column=0, sticky="ews") 461 self.canvas['yscrollcommand'] = self.vbar.set 462 self.vbar['command'] = self.canvas.yview 463 self.canvas['xscrollcommand'] = self.hbar.set 464 self.hbar['command'] = self.canvas.xview 465 self.canvas.bind("<Key-Prior>", self.page_up) 466 self.canvas.bind("<Key-Next>", self.page_down) 467 self.canvas.bind("<Key-Up>", self.unit_up) 468 self.canvas.bind("<Key-Down>", self.unit_down) 469 self.canvas.bind("<MouseWheel>", wheel_event) 470 if self.canvas._windowingsystem == 'x11': 471 self.canvas.bind("<Button-4>", wheel_event) 472 self.canvas.bind("<Button-5>", wheel_event) 473 #if isinstance(master, Toplevel) or isinstance(master, Tk): 474 self.canvas.bind("<Alt-Key-2>", self.zoom_height) 475 self.canvas.focus_set() 476 def page_up(self, event): 477 self.canvas.yview_scroll(-1, "page") 478 return "break" 479 def page_down(self, event): 480 self.canvas.yview_scroll(1, "page") 481 return "break" 482 def unit_up(self, event): 483 self.canvas.yview_scroll(-1, "unit") 484 return "break" 485 def unit_down(self, event): 486 self.canvas.yview_scroll(1, "unit") 487 return "break" 488 def zoom_height(self, event): 489 zoomheight.zoom_height(self.master) 490 return "break" 491 492 493def _tree_widget(parent): # htest # 494 top = Toplevel(parent) 495 x, y = map(int, parent.geometry().split('+')[1:]) 496 top.geometry("+%d+%d" % (x+50, y+175)) 497 sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1) 498 sc.frame.pack(expand=1, fill="both", side=LEFT) 499 item = FileTreeItem(ICONDIR) 500 node = TreeNode(sc.canvas, None, item) 501 node.expand() 502 503 504if __name__ == '__main__': 505 from unittest import main 506 main('idlelib.idle_test.test_tree', verbosity=2, exit=False) 507 508 from idlelib.idle_test.htest import run 509 run(_tree_widget) 510