blob: 5947268f5c35aead90951dbd2eae9475d37e5531 [file] [log] [blame]
David Scherer7aced172000-08-15 01:13:23 +00001# 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
Terry Jan Reedybfbaa6b2016-08-31 00:50:55 -040018
Georg Brandl14fc4272008-05-17 18:39:55 +000019from tkinter import *
Terry Jan Reedyaff0ada2019-01-02 22:04:06 -050020from tkinter.ttk import Frame, Scrollbar
Terry Jan Reedybfbaa6b2016-08-31 00:50:55 -040021
Terry Jan Reedy6fa5bdc2016-05-28 13:22:31 -040022from idlelib.config import idleConf
Terry Jan Reedybfbaa6b2016-08-31 00:50:55 -040023from idlelib import zoomheight
David Scherer7aced172000-08-15 01:13:23 +000024
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):
Kurt B. Kaiserad667422007-08-23 01:06:15 +000035 raise RuntimeError("can't find icon directory (%r)" % (ICONDIR,))
David Scherer7aced172000-08-15 01:13:23 +000036
37def listicons(icondir=ICONDIR):
38 """Utility to display the available icons."""
39 root = Tk()
40 import glob
Serhiy Storchaka93558682020-06-20 11:10:31 +030041 list = glob.glob(os.path.join(glob.escape(icondir), "*.gif"))
David Scherer7aced172000-08-15 01:13:23 +000042 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
GeeTransit2cd90252019-09-04 21:33:34 -040059def 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
David Scherer7aced172000-08-15 01:13:23 +000083
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'
Neal Norwitz672ce572002-11-30 19:04:07 +000091 self.selected = False
David Scherer7aced172000-08-15 01:13:23 +000092 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()
Neal Norwitz672ce572002-11-30 19:04:07 +0000118 self.selected = True
David Scherer7aced172000-08-15 01:13:23 +0000119 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
Neal Norwitz672ce572002-11-30 19:04:07 +0000126 self.selected = False
David Scherer7aced172000-08-15 01:13:23 +0000127 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!
Terry Jan Reedy9a6f8e12014-10-16 23:12:48 -0400202 dy = 20
David Scherer7aced172000-08-15 01:13:23 +0000203 self.x, self.y = x, y
204 self.drawicon()
205 self.drawtext()
206 if self.state != 'expanded':
Terry Jan Reedy9a6f8e12014-10-16 23:12:48 -0400207 return y + dy
David Scherer7aced172000-08-15 01:13:23 +0000208 # 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:
Steven M. Gava72de9c72002-05-27 21:58:05 +0000215 child = self.__class__(self.canvas, self, item)
David Scherer7aced172000-08-15 01:13:23 +0000216 self.children.append(child)
217 cx = x+20
Terry Jan Reedy9a6f8e12014-10-16 23:12:48 -0400218 cy = y + dy
David Scherer7aced172000-08-15 01:13:23 +0000219 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
Terry Jan Reedy9a6f8e12014-10-16 23:12:48 -0400257 texty = self.y-4
David Scherer7aced172000-08-15 01:13:23 +0000258 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:
Terry Jan Reedy27336182015-05-14 18:10:50 -0400274 self.label
David Scherer7aced172000-08-15 01:13:23 +0000275 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)
Terry Jan Reedyd0c0f002015-11-12 15:02:57 -0500278 theme = idleConf.CurrentTheme()
David Scherer7aced172000-08-15 01:13:23 +0000279 if self.selected:
Kurt B. Kaiser73360a32004-03-08 18:15:31 +0000280 self.label.configure(idleConf.GetHighlight(theme, 'hilite'))
David Scherer7aced172000-08-15 01:13:23 +0000281 else:
Kurt B. Kaiser73360a32004-03-08 18:15:31 +0000282 self.label.configure(idleConf.GetHighlight(theme, 'normal'))
David Scherer7aced172000-08-15 01:13:23 +0000283 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)
GeeTransit2cd90252019-09-04 21:33:34 -0400287 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))
David Scherer7aced172000-08-15 01:13:23 +0000290 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):
Kurt B. Kaiser2a5e3502002-09-18 03:26:47 +0000323 try:
324 entry = self.entry
325 del self.entry
326 except AttributeError:
327 return
328 entry.destroy()
David Scherer7aced172000-08-15 01:13:23 +0000329 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
Andrew Svetlov786fbd82012-12-17 19:51:15 +0200414 except OSError:
David Scherer7aced172000-08-15 01:13:23 +0000415 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)
Andrew Svetlov786fbd82012-12-17 19:51:15 +0200427 except OSError:
David Scherer7aced172000-08-15 01:13:23 +0000428 return []
Raymond Hettingerd4cb56d2008-01-30 02:55:10 +0000429 names.sort(key = os.path.normcase)
David Scherer7aced172000-08-15 01:13:23 +0000430 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:
GeeTransit2cd90252019-09-04 21:33:34 -0400440
David Scherer7aced172000-08-15 01:13:23 +0000441 def __init__(self, master, **opts):
Guido van Rossum811c4e02006-08-22 15:45:46 +0000442 if 'yscrollincrement' not in opts:
David Scherer7aced172000-08-15 01:13:23 +0000443 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)
Raymond Hettinger931237e2003-07-09 18:48:24 +0000448 self.canvas = Canvas(self.frame, **opts)
David Scherer7aced172000-08-15 01:13:23 +0000449 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)
GeeTransit2cd90252019-09-04 21:33:34 -0400462 self.canvas.bind("<MouseWheel>", wheel_event)
463 self.canvas.bind("<Button-4>", wheel_event)
464 self.canvas.bind("<Button-5>", wheel_event)
Kurt B. Kaiser98134452003-01-22 00:31:09 +0000465 #if isinstance(master, Toplevel) or isinstance(master, Tk):
466 self.canvas.bind("<Alt-Key-2>", self.zoom_height)
David Scherer7aced172000-08-15 01:13:23 +0000467 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):
Terry Jan Reedy6fa5bdc2016-05-28 13:22:31 -0400481 zoomheight.zoom_height(self.master)
David Scherer7aced172000-08-15 01:13:23 +0000482 return "break"
483
484
Terry Jan Reedy01e35752016-06-10 18:19:21 -0400485def _tree_widget(parent): # htest #
486 top = Toplevel(parent)
Terry Jan Reedya7480322016-07-10 17:28:10 -0400487 x, y = map(int, parent.geometry().split('+')[1:])
488 top.geometry("+%d+%d" % (x+50, y+175))
Terry Jan Reedy01e35752016-06-10 18:19:21 -0400489 sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1)
Terry Jan Reedy1b392ff2014-05-24 18:48:18 -0400490 sc.frame.pack(expand=1, fill="both", side=LEFT)
Terry Jan Reedy01e35752016-06-10 18:19:21 -0400491 item = FileTreeItem(ICONDIR)
David Scherer7aced172000-08-15 01:13:23 +0000492 node = TreeNode(sc.canvas, None, item)
493 node.expand()
David Scherer7aced172000-08-15 01:13:23 +0000494
495if __name__ == '__main__':
Terry Jan Reedy4d921582018-06-19 19:12:52 -0400496 from unittest import main
497 main('idlelib.idle_test.test_tree', verbosity=2, exit=False)
498
Terry Jan Reedy1b392ff2014-05-24 18:48:18 -0400499 from idlelib.idle_test.htest import run
500 run(_tree_widget)