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