Home | History | Annotate | Download | only in idlelib
      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 
     19 from tkinter import *
     20 from tkinter.ttk import Frame, Scrollbar
     21 
     22 from idlelib.config import idleConf
     23 from idlelib import zoomheight
     24 
     25 ICONDIR = "Icons"
     26 
     27 # Look for Icons subdirectory in the same directory as this module
     28 try:
     29     _icondir = os.path.join(os.path.dirname(__file__), ICONDIR)
     30 except NameError:
     31     _icondir = ICONDIR
     32 if os.path.isdir(_icondir):
     33     ICONDIR = _icondir
     34 elif not os.path.isdir(ICONDIR):
     35     raise RuntimeError("can't find icon directory (%r)" % (ICONDIR,))
     36 
     37 def 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 
     60 class 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 
    306 class 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 
    366 class 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 
    412 class 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 
    454 def _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 
    464 if __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