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("can't find icon directory (%r)" % (ICONDIR,)) 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 def __init__(self, canvas, parent, item): 87 self.canvas = canvas 88 self.parent = parent 89 self.item = item 90 self.state = 'collapsed' 91 self.selected = False 92 self.children = [] 93 self.x = self.y = None 94 self.iconimages = {} # cache of PhotoImage instances for icons 95 96 def destroy(self): 97 for c in self.children[:]: 98 self.children.remove(c) 99 c.destroy() 100 self.parent = None 101 102 def geticonimage(self, name): 103 try: 104 return self.iconimages[name] 105 except KeyError: 106 pass 107 file, ext = os.path.splitext(name) 108 ext = ext or ".gif" 109 fullname = os.path.join(ICONDIR, file + ext) 110 image = PhotoImage(master=self.canvas, file=fullname) 111 self.iconimages[name] = image 112 return image 113 114 def select(self, event=None): 115 if self.selected: 116 return 117 self.deselectall() 118 self.selected = True 119 self.canvas.delete(self.image_id) 120 self.drawicon() 121 self.drawtext() 122 123 def deselect(self, event=None): 124 if not self.selected: 125 return 126 self.selected = False 127 self.canvas.delete(self.image_id) 128 self.drawicon() 129 self.drawtext() 130 131 def deselectall(self): 132 if self.parent: 133 self.parent.deselectall() 134 else: 135 self.deselecttree() 136 137 def deselecttree(self): 138 if self.selected: 139 self.deselect() 140 for child in self.children: 141 child.deselecttree() 142 143 def flip(self, event=None): 144 if self.state == 'expanded': 145 self.collapse() 146 else: 147 self.expand() 148 self.item.OnDoubleClick() 149 return "break" 150 151 def expand(self, event=None): 152 if not self.item._IsExpandable(): 153 return 154 if self.state != 'expanded': 155 self.state = 'expanded' 156 self.update() 157 self.view() 158 159 def collapse(self, event=None): 160 if self.state != 'collapsed': 161 self.state = 'collapsed' 162 self.update() 163 164 def view(self): 165 top = self.y - 2 166 bottom = self.lastvisiblechild().y + 17 167 height = bottom - top 168 visible_top = self.canvas.canvasy(0) 169 visible_height = self.canvas.winfo_height() 170 visible_bottom = self.canvas.canvasy(visible_height) 171 if visible_top <= top and bottom <= visible_bottom: 172 return 173 x0, y0, x1, y1 = self.canvas._getints(self.canvas['scrollregion']) 174 if top >= visible_top and height <= visible_height: 175 fraction = top + height - visible_height 176 else: 177 fraction = top 178 fraction = float(fraction) / y1 179 self.canvas.yview_moveto(fraction) 180 181 def lastvisiblechild(self): 182 if self.children and self.state == 'expanded': 183 return self.children[-1].lastvisiblechild() 184 else: 185 return self 186 187 def update(self): 188 if self.parent: 189 self.parent.update() 190 else: 191 oldcursor = self.canvas['cursor'] 192 self.canvas['cursor'] = "watch" 193 self.canvas.update() 194 self.canvas.delete(ALL) # XXX could be more subtle 195 self.draw(7, 2) 196 x0, y0, x1, y1 = self.canvas.bbox(ALL) 197 self.canvas.configure(scrollregion=(0, 0, x1, y1)) 198 self.canvas['cursor'] = oldcursor 199 200 def draw(self, x, y): 201 # XXX This hard-codes too many geometry constants! 202 dy = 20 203 self.x, self.y = x, y 204 self.drawicon() 205 self.drawtext() 206 if self.state != 'expanded': 207 return y + dy 208 # draw children 209 if not self.children: 210 sublist = self.item._GetSubList() 211 if not sublist: 212 # _IsExpandable() was mistaken; that's allowed 213 return y+17 214 for item in sublist: 215 child = self.__class__(self.canvas, self, item) 216 self.children.append(child) 217 cx = x+20 218 cy = y + dy 219 cylast = 0 220 for child in self.children: 221 cylast = cy 222 self.canvas.create_line(x+9, cy+7, cx, cy+7, fill="gray50") 223 cy = child.draw(cx, cy) 224 if child.item._IsExpandable(): 225 if child.state == 'expanded': 226 iconname = "minusnode" 227 callback = child.collapse 228 else: 229 iconname = "plusnode" 230 callback = child.expand 231 image = self.geticonimage(iconname) 232 id = self.canvas.create_image(x+9, cylast+7, image=image) 233 # XXX This leaks bindings until canvas is deleted: 234 self.canvas.tag_bind(id, "<1>", callback) 235 self.canvas.tag_bind(id, "<Double-1>", lambda x: None) 236 id = self.canvas.create_line(x+9, y+10, x+9, cylast+7, 237 ##stipple="gray50", # XXX Seems broken in Tk 8.0.x 238 fill="gray50") 239 self.canvas.tag_lower(id) # XXX .lower(id) before Python 1.5.2 240 return cy 241 242 def drawicon(self): 243 if self.selected: 244 imagename = (self.item.GetSelectedIconName() or 245 self.item.GetIconName() or 246 "openfolder") 247 else: 248 imagename = self.item.GetIconName() or "folder" 249 image = self.geticonimage(imagename) 250 id = self.canvas.create_image(self.x, self.y, anchor="nw", image=image) 251 self.image_id = id 252 self.canvas.tag_bind(id, "<1>", self.select) 253 self.canvas.tag_bind(id, "<Double-1>", self.flip) 254 255 def drawtext(self): 256 textx = self.x+20-1 257 texty = self.y-4 258 labeltext = self.item.GetLabelText() 259 if labeltext: 260 id = self.canvas.create_text(textx, texty, anchor="nw", 261 text=labeltext) 262 self.canvas.tag_bind(id, "<1>", self.select) 263 self.canvas.tag_bind(id, "<Double-1>", self.flip) 264 x0, y0, x1, y1 = self.canvas.bbox(id) 265 textx = max(x1, 200) + 10 266 text = self.item.GetText() or "<no text>" 267 try: 268 self.entry 269 except AttributeError: 270 pass 271 else: 272 self.edit_finish() 273 try: 274 self.label 275 except AttributeError: 276 # padding carefully selected (on Windows) to match Entry widget: 277 self.label = Label(self.canvas, text=text, bd=0, padx=2, pady=2) 278 theme = idleConf.CurrentTheme() 279 if self.selected: 280 self.label.configure(idleConf.GetHighlight(theme, 'hilite')) 281 else: 282 self.label.configure(idleConf.GetHighlight(theme, 'normal')) 283 id = self.canvas.create_window(textx, texty, 284 anchor="nw", window=self.label) 285 self.label.bind("<1>", self.select_or_edit) 286 self.label.bind("<Double-1>", self.flip) 287 self.label.bind("<MouseWheel>", lambda e: wheel_event(e, self.canvas)) 288 self.label.bind("<Button-4>", lambda e: wheel_event(e, self.canvas)) 289 self.label.bind("<Button-5>", lambda e: wheel_event(e, self.canvas)) 290 self.text_id = id 291 292 def select_or_edit(self, event=None): 293 if self.selected and self.item.IsEditable(): 294 self.edit(event) 295 else: 296 self.select(event) 297 298 def edit(self, event=None): 299 self.entry = Entry(self.label, bd=0, highlightthickness=1, width=0) 300 self.entry.insert(0, self.label['text']) 301 self.entry.selection_range(0, END) 302 self.entry.pack(ipadx=5) 303 self.entry.focus_set() 304 self.entry.bind("<Return>", self.edit_finish) 305 self.entry.bind("<Escape>", self.edit_cancel) 306 307 def edit_finish(self, event=None): 308 try: 309 entry = self.entry 310 del self.entry 311 except AttributeError: 312 return 313 text = entry.get() 314 entry.destroy() 315 if text and text != self.item.GetText(): 316 self.item.SetText(text) 317 text = self.item.GetText() 318 self.label['text'] = text 319 self.drawtext() 320 self.canvas.focus_set() 321 322 def edit_cancel(self, event=None): 323 try: 324 entry = self.entry 325 del self.entry 326 except AttributeError: 327 return 328 entry.destroy() 329 self.drawtext() 330 self.canvas.focus_set() 331 332 333class TreeItem: 334 335 """Abstract class representing tree items. 336 337 Methods should typically be overridden, otherwise a default action 338 is used. 339 340 """ 341 342 def __init__(self): 343 """Constructor. Do whatever you need to do.""" 344 345 def GetText(self): 346 """Return text string to display.""" 347 348 def GetLabelText(self): 349 """Return label text string to display in front of text (if any).""" 350 351 expandable = None 352 353 def _IsExpandable(self): 354 """Do not override! Called by TreeNode.""" 355 if self.expandable is None: 356 self.expandable = self.IsExpandable() 357 return self.expandable 358 359 def IsExpandable(self): 360 """Return whether there are subitems.""" 361 return 1 362 363 def _GetSubList(self): 364 """Do not override! Called by TreeNode.""" 365 if not self.IsExpandable(): 366 return [] 367 sublist = self.GetSubList() 368 if not sublist: 369 self.expandable = 0 370 return sublist 371 372 def IsEditable(self): 373 """Return whether the item's text may be edited.""" 374 375 def SetText(self, text): 376 """Change the item's text (if it is editable).""" 377 378 def GetIconName(self): 379 """Return name of icon to be displayed normally.""" 380 381 def GetSelectedIconName(self): 382 """Return name of icon to be displayed when selected.""" 383 384 def GetSubList(self): 385 """Return list of items forming sublist.""" 386 387 def OnDoubleClick(self): 388 """Called on a double-click on the item.""" 389 390 391# Example application 392 393class FileTreeItem(TreeItem): 394 395 """Example TreeItem subclass -- browse the file system.""" 396 397 def __init__(self, path): 398 self.path = path 399 400 def GetText(self): 401 return os.path.basename(self.path) or self.path 402 403 def IsEditable(self): 404 return os.path.basename(self.path) != "" 405 406 def SetText(self, text): 407 newpath = os.path.dirname(self.path) 408 newpath = os.path.join(newpath, text) 409 if os.path.dirname(newpath) != os.path.dirname(self.path): 410 return 411 try: 412 os.rename(self.path, newpath) 413 self.path = newpath 414 except OSError: 415 pass 416 417 def GetIconName(self): 418 if not self.IsExpandable(): 419 return "python" # XXX wish there was a "file" icon 420 421 def IsExpandable(self): 422 return os.path.isdir(self.path) 423 424 def GetSubList(self): 425 try: 426 names = os.listdir(self.path) 427 except OSError: 428 return [] 429 names.sort(key = os.path.normcase) 430 sublist = [] 431 for name in names: 432 item = FileTreeItem(os.path.join(self.path, name)) 433 sublist.append(item) 434 return sublist 435 436 437# A canvas widget with scroll bars and some useful bindings 438 439class ScrolledCanvas: 440 441 def __init__(self, master, **opts): 442 if 'yscrollincrement' not in opts: 443 opts['yscrollincrement'] = 17 444 self.master = master 445 self.frame = Frame(master) 446 self.frame.rowconfigure(0, weight=1) 447 self.frame.columnconfigure(0, weight=1) 448 self.canvas = Canvas(self.frame, **opts) 449 self.canvas.grid(row=0, column=0, sticky="nsew") 450 self.vbar = Scrollbar(self.frame, name="vbar") 451 self.vbar.grid(row=0, column=1, sticky="nse") 452 self.hbar = Scrollbar(self.frame, name="hbar", orient="horizontal") 453 self.hbar.grid(row=1, column=0, sticky="ews") 454 self.canvas['yscrollcommand'] = self.vbar.set 455 self.vbar['command'] = self.canvas.yview 456 self.canvas['xscrollcommand'] = self.hbar.set 457 self.hbar['command'] = self.canvas.xview 458 self.canvas.bind("<Key-Prior>", self.page_up) 459 self.canvas.bind("<Key-Next>", self.page_down) 460 self.canvas.bind("<Key-Up>", self.unit_up) 461 self.canvas.bind("<Key-Down>", self.unit_down) 462 self.canvas.bind("<MouseWheel>", wheel_event) 463 self.canvas.bind("<Button-4>", wheel_event) 464 self.canvas.bind("<Button-5>", wheel_event) 465 #if isinstance(master, Toplevel) or isinstance(master, Tk): 466 self.canvas.bind("<Alt-Key-2>", self.zoom_height) 467 self.canvas.focus_set() 468 def page_up(self, event): 469 self.canvas.yview_scroll(-1, "page") 470 return "break" 471 def page_down(self, event): 472 self.canvas.yview_scroll(1, "page") 473 return "break" 474 def unit_up(self, event): 475 self.canvas.yview_scroll(-1, "unit") 476 return "break" 477 def unit_down(self, event): 478 self.canvas.yview_scroll(1, "unit") 479 return "break" 480 def zoom_height(self, event): 481 zoomheight.zoom_height(self.master) 482 return "break" 483 484 485def _tree_widget(parent): # htest # 486 top = Toplevel(parent) 487 x, y = map(int, parent.geometry().split('+')[1:]) 488 top.geometry("+%d+%d" % (x+50, y+175)) 489 sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1) 490 sc.frame.pack(expand=1, fill="both", side=LEFT) 491 item = FileTreeItem(ICONDIR) 492 node = TreeNode(sc.canvas, None, item) 493 node.expand() 494 495if __name__ == '__main__': 496 from unittest import main 497 main('idlelib.idle_test.test_tree', verbosity=2, exit=False) 498 499 from idlelib.idle_test.htest import run 500 run(_tree_widget) 501