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 17 import os 18 from Tkinter import * 19 import imp 20 21 from idlelib import ZoomHeight 22 from idlelib.configHandler import idleConf 23 24 ICONDIR = "Icons" 25 26 # Look for Icons subdirectory in the same directory as this module 27 try: 28 _icondir = os.path.join(os.path.dirname(__file__), ICONDIR) 29 except NameError: 30 _icondir = ICONDIR 31 if os.path.isdir(_icondir): 32 ICONDIR = _icondir 33 elif not os.path.isdir(ICONDIR): 34 raise RuntimeError, "can't find icon directory (%r)" % (ICONDIR,) 35 36 def 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 59 class 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 self.x, self.y = x, y 178 self.drawicon() 179 self.drawtext() 180 if self.state != 'expanded': 181 return y+17 182 # draw children 183 if not self.children: 184 sublist = self.item._GetSubList() 185 if not sublist: 186 # _IsExpandable() was mistaken; that's allowed 187 return y+17 188 for item in sublist: 189 child = self.__class__(self.canvas, self, item) 190 self.children.append(child) 191 cx = x+20 192 cy = y+17 193 cylast = 0 194 for child in self.children: 195 cylast = cy 196 self.canvas.create_line(x+9, cy+7, cx, cy+7, fill="gray50") 197 cy = child.draw(cx, cy) 198 if child.item._IsExpandable(): 199 if child.state == 'expanded': 200 iconname = "minusnode" 201 callback = child.collapse 202 else: 203 iconname = "plusnode" 204 callback = child.expand 205 image = self.geticonimage(iconname) 206 id = self.canvas.create_image(x+9, cylast+7, image=image) 207 # XXX This leaks bindings until canvas is deleted: 208 self.canvas.tag_bind(id, "<1>", callback) 209 self.canvas.tag_bind(id, "<Double-1>", lambda x: None) 210 id = self.canvas.create_line(x+9, y+10, x+9, cylast+7, 211 ##stipple="gray50", # XXX Seems broken in Tk 8.0.x 212 fill="gray50") 213 self.canvas.tag_lower(id) # XXX .lower(id) before Python 1.5.2 214 return cy 215 216 def drawicon(self): 217 if self.selected: 218 imagename = (self.item.GetSelectedIconName() or 219 self.item.GetIconName() or 220 "openfolder") 221 else: 222 imagename = self.item.GetIconName() or "folder" 223 image = self.geticonimage(imagename) 224 id = self.canvas.create_image(self.x, self.y, anchor="nw", image=image) 225 self.image_id = id 226 self.canvas.tag_bind(id, "<1>", self.select) 227 self.canvas.tag_bind(id, "<Double-1>", self.flip) 228 229 def drawtext(self): 230 textx = self.x+20-1 231 texty = self.y-1 232 labeltext = self.item.GetLabelText() 233 if labeltext: 234 id = self.canvas.create_text(textx, texty, anchor="nw", 235 text=labeltext) 236 self.canvas.tag_bind(id, "<1>", self.select) 237 self.canvas.tag_bind(id, "<Double-1>", self.flip) 238 x0, y0, x1, y1 = self.canvas.bbox(id) 239 textx = max(x1, 200) + 10 240 text = self.item.GetText() or "<no text>" 241 try: 242 self.entry 243 except AttributeError: 244 pass 245 else: 246 self.edit_finish() 247 try: 248 label = self.label 249 except AttributeError: 250 # padding carefully selected (on Windows) to match Entry widget: 251 self.label = Label(self.canvas, text=text, bd=0, padx=2, pady=2) 252 theme = idleConf.GetOption('main','Theme','name') 253 if self.selected: 254 self.label.configure(idleConf.GetHighlight(theme, 'hilite')) 255 else: 256 self.label.configure(idleConf.GetHighlight(theme, 'normal')) 257 id = self.canvas.create_window(textx, texty, 258 anchor="nw", window=self.label) 259 self.label.bind("<1>", self.select_or_edit) 260 self.label.bind("<Double-1>", self.flip) 261 self.text_id = id 262 263 def select_or_edit(self, event=None): 264 if self.selected and self.item.IsEditable(): 265 self.edit(event) 266 else: 267 self.select(event) 268 269 def edit(self, event=None): 270 self.entry = Entry(self.label, bd=0, highlightthickness=1, width=0) 271 self.entry.insert(0, self.label['text']) 272 self.entry.selection_range(0, END) 273 self.entry.pack(ipadx=5) 274 self.entry.focus_set() 275 self.entry.bind("<Return>", self.edit_finish) 276 self.entry.bind("<Escape>", self.edit_cancel) 277 278 def edit_finish(self, event=None): 279 try: 280 entry = self.entry 281 del self.entry 282 except AttributeError: 283 return 284 text = entry.get() 285 entry.destroy() 286 if text and text != self.item.GetText(): 287 self.item.SetText(text) 288 text = self.item.GetText() 289 self.label['text'] = text 290 self.drawtext() 291 self.canvas.focus_set() 292 293 def edit_cancel(self, event=None): 294 try: 295 entry = self.entry 296 del self.entry 297 except AttributeError: 298 return 299 entry.destroy() 300 self.drawtext() 301 self.canvas.focus_set() 302 303 304 class TreeItem: 305 306 """Abstract class representing tree items. 307 308 Methods should typically be overridden, otherwise a default action 309 is used. 310 311 """ 312 313 def __init__(self): 314 """Constructor. Do whatever you need to do.""" 315 316 def GetText(self): 317 """Return text string to display.""" 318 319 def GetLabelText(self): 320 """Return label text string to display in front of text (if any).""" 321 322 expandable = None 323 324 def _IsExpandable(self): 325 """Do not override! Called by TreeNode.""" 326 if self.expandable is None: 327 self.expandable = self.IsExpandable() 328 return self.expandable 329 330 def IsExpandable(self): 331 """Return whether there are subitems.""" 332 return 1 333 334 def _GetSubList(self): 335 """Do not override! Called by TreeNode.""" 336 if not self.IsExpandable(): 337 return [] 338 sublist = self.GetSubList() 339 if not sublist: 340 self.expandable = 0 341 return sublist 342 343 def IsEditable(self): 344 """Return whether the item's text may be edited.""" 345 346 def SetText(self, text): 347 """Change the item's text (if it is editable).""" 348 349 def GetIconName(self): 350 """Return name of icon to be displayed normally.""" 351 352 def GetSelectedIconName(self): 353 """Return name of icon to be displayed when selected.""" 354 355 def GetSubList(self): 356 """Return list of items forming sublist.""" 357 358 def OnDoubleClick(self): 359 """Called on a double-click on the item.""" 360 361 362 # Example application 363 364 class FileTreeItem(TreeItem): 365 366 """Example TreeItem subclass -- browse the file system.""" 367 368 def __init__(self, path): 369 self.path = path 370 371 def GetText(self): 372 return os.path.basename(self.path) or self.path 373 374 def IsEditable(self): 375 return os.path.basename(self.path) != "" 376 377 def SetText(self, text): 378 newpath = os.path.dirname(self.path) 379 newpath = os.path.join(newpath, text) 380 if os.path.dirname(newpath) != os.path.dirname(self.path): 381 return 382 try: 383 os.rename(self.path, newpath) 384 self.path = newpath 385 except os.error: 386 pass 387 388 def GetIconName(self): 389 if not self.IsExpandable(): 390 return "python" # XXX wish there was a "file" icon 391 392 def IsExpandable(self): 393 return os.path.isdir(self.path) 394 395 def GetSubList(self): 396 try: 397 names = os.listdir(self.path) 398 except os.error: 399 return [] 400 names.sort(key = os.path.normcase) 401 sublist = [] 402 for name in names: 403 item = FileTreeItem(os.path.join(self.path, name)) 404 sublist.append(item) 405 return sublist 406 407 408 # A canvas widget with scroll bars and some useful bindings 409 410 class ScrolledCanvas: 411 def __init__(self, master, **opts): 412 if 'yscrollincrement' not in opts: 413 opts['yscrollincrement'] = 17 414 self.master = master 415 self.frame = Frame(master) 416 self.frame.rowconfigure(0, weight=1) 417 self.frame.columnconfigure(0, weight=1) 418 self.canvas = Canvas(self.frame, **opts) 419 self.canvas.grid(row=0, column=0, sticky="nsew") 420 self.vbar = Scrollbar(self.frame, name="vbar") 421 self.vbar.grid(row=0, column=1, sticky="nse") 422 self.hbar = Scrollbar(self.frame, name="hbar", orient="horizontal") 423 self.hbar.grid(row=1, column=0, sticky="ews") 424 self.canvas['yscrollcommand'] = self.vbar.set 425 self.vbar['command'] = self.canvas.yview 426 self.canvas['xscrollcommand'] = self.hbar.set 427 self.hbar['command'] = self.canvas.xview 428 self.canvas.bind("<Key-Prior>", self.page_up) 429 self.canvas.bind("<Key-Next>", self.page_down) 430 self.canvas.bind("<Key-Up>", self.unit_up) 431 self.canvas.bind("<Key-Down>", self.unit_down) 432 #if isinstance(master, Toplevel) or isinstance(master, Tk): 433 self.canvas.bind("<Alt-Key-2>", self.zoom_height) 434 self.canvas.focus_set() 435 def page_up(self, event): 436 self.canvas.yview_scroll(-1, "page") 437 return "break" 438 def page_down(self, event): 439 self.canvas.yview_scroll(1, "page") 440 return "break" 441 def unit_up(self, event): 442 self.canvas.yview_scroll(-1, "unit") 443 return "break" 444 def unit_down(self, event): 445 self.canvas.yview_scroll(1, "unit") 446 return "break" 447 def zoom_height(self, event): 448 ZoomHeight.zoom_height(self.master) 449 return "break" 450 451 452 # Testing functions 453 454 def test(): 455 from idlelib import PyShell 456 root = Toplevel(PyShell.root) 457 root.configure(bd=0, bg="yellow") 458 root.focus_set() 459 sc = ScrolledCanvas(root, bg="white", highlightthickness=0, takefocus=1) 460 sc.frame.pack(expand=1, fill="both") 461 item = FileTreeItem("C:/windows/desktop") 462 node = TreeNode(sc.canvas, None, item) 463 node.expand() 464 465 def test2(): 466 # test w/o scrolling canvas 467 root = Tk() 468 root.configure(bd=0) 469 canvas = Canvas(root, bg="white", highlightthickness=0) 470 canvas.pack(expand=1, fill="both") 471 item = FileTreeItem(os.curdir) 472 node = TreeNode(canvas, None, item) 473 node.update() 474 canvas.focus_set() 475 476 if __name__ == '__main__': 477 test() 478