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