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