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