from glob import glob from operator import itemgetter import tkinter as tk from tkinter import ttk from tkinter import messagebox from PIL import Image, ImageTk import matplotlib import win32api import os from . import windowsIcons MENU_DELETE = "menu_delete" MENU_OPEN = "open" MENU_CLIPBOARD = "clipboard" ALL = [MENU_DELETE, MENU_OPEN, MENU_CLIPBOARD] class TkFileBrowser(tk.Frame): _open = [] def __init__(self, parent, command, rightclick_options = ALL, refresh = 20, types = [], showhidden = False): """Widget for browsing a windows filesystem. Arguments: parent {tk.Frame} -- parent frame command {function} -- Called when the user clicks on a file Keyword Arguments: rightclick_options {list} -- the options to show up in the menu when the user right clicks. Choose from [MENU_DELETE, MENU_OPEN, MENU_CLIPBOARD] or just use ALL (default: ALL) refresh {int} -- how often to refresh browser (ms) (default: {20}) types {list} -- file types that show up. Leave blank for all files: e e.g. [".png", ".jpg"] (default: {[]}) showhiddem {bool} -- should the tree show hidden files or not (default: {False}) """ tk.Frame.__init__(self, parent) self._parent = parent self._command = command self._rightclick_options = rightclick_options self._refresh = refresh self._types = types self._showhidden = showhidden self._img_clipboard = tk.PhotoImage(file = os.path.join(os.path.dirname(__file__), "Assets", "clipboard.png")) self._img_cross = tk.PhotoImage(file = os.path.join(os.path.dirname(__file__), "Assets", "cross.png")) self._book = DriveBook(self) self._book.pack(fill = tk.BOTH, expand = True) self.after(self._refresh, self.refresh) #TODO: fix a bug here def see(self, path): """Open the tree and book to this folder Arguments: path {str} -- path to open to """ if not os.path.exists(path): print("The system couldn't find the path: '%s'" % path) return #open the correct book tab split = path.replace("\\", "/").split("/") #if requested is file, go to the folder in which it's in if os.path.isfile(path): del split[-1] drive = split[0] + "\\" self._book.select(self._book._get_drives().index(drive)) try: self._book._tabs[drive]._tree.see("\\".join(split)) except tk.TclError: #the node probably isn't loaded yet so we have to make it load before #using .see() for i in range(len(split) - 1): self._book._tabs[drive]._populate_path("\\".join(split[:i+2])) self._book._tabs[drive]._tree.item("\\".join(split[:i+2]), open = True) #add to list of open nodes self._open.append([split[0]+"\\", "\\".join(split[:i+2])]) self._book._tabs[drive]._tree.see("\\".join(split)) def refresh(self): def get_drive_and_path(search): out = [] for i in self._open: if i[1] == search: out.append(i) return out self._book._refresh() print(self._open) #work out which open nodes need to be updated nodes_to_refresh = [] for i in self._open: dir = i[1] if not os.path.exists(dir): continue #work out all the dirs that the node is showing, ignoring the dummy by checking for paths childirs = [p.split("\\")[-1] for p in self._book._tabs[i[0]]._tree.get_children(dir) if "\\" in p] #work out all the dirs that are in the file system. Use our own method so that settings #are still here, show hidden files, etc. files, folders = self._book._tabs[i[0]]._get_dirs_in_path(dir) actualdirs = files + folders if actualdirs != childirs: nodes_to_refresh.append(dir) #add drives to the list of stuff to check for updates for drive in self._book._get_drives(): childirs = [p.split("\\")[-1] for p in self._book._tabs[drive]._tree.get_children("")] files, folders = self._book._tabs[drive]._get_dirs_in_path(drive) actualdirs = files + folders if actualdirs != childirs: nodes_to_refresh.append(drive) #print(nodes_to_refresh) drive_and_node = [] for node in nodes_to_refresh: for drive, node1 in get_drive_and_path(node): orignode = node1 if node1 == drive: node1 = "" #print("node: ", node1, "\ndrive: ", drive) tree = self._book._tabs[drive]._tree children = tree.get_children(node1) #get the full path of removals and additons glob_pattern = os.path.join(orignode, "*") deletions = list(set(list(children)) - set(sorted(glob(glob_pattern), key=os.path.getctime))) additions = list(set(sorted(glob(glob_pattern), key=os.path.getctime)) - set(list(children))) #print("deletions: ", deletions, "\nadditions: ", additions) #delete from tree for deletion in deletions: tree.delete(deletion) #if the node is open, and it's just been deleteted, remove it from the list if [drive.replace("\\", "\\"), deletion.replace("\\", "\\")] in self._open: self._open.remove([drive.replace("\\", "\\"), deletion.replace("\\", "\\")]) #add new entries in the correct place, highlighting them and getting appropitate icons for addition in additions: if os.path.isdir(addition): #make an icon if it doesn't already exist if addition not in self._book._foldericons: self._book._foldericons[addition] = ImageTk.PhotoImage( self._get_icon(addition)) #get the name folder = addition.split("\\")[-1] #work out all the other folders so we know an approprate place to put the new folder glob_pattern = os.path.join(orignode, "*") folders = [i for i in sorted(glob(glob_pattern), key=os.path.getctime) if os.path.isdir(i)] tree.insert( parent = node, index = folders.index(addition), #insert in an appropriate place iid = addition, tags = (addition, ), text = folder, image = self._book._foldericons[addition], values = [folder, "", ""]) #setup a dummy so the '+' appears before it's loaded. Child stuff will be loaded when the user #clicks on the plus, and the dummy will be removed. (or not if there are no files) tree.insert(parent = addition, index = tk.END, tag = "dummy", text = "No avaliable files") elif os.path.isfile(addition): _, type_ = os.path.splitext(addition) if type_ == ".lnk" or type_ == ".exe": if addition not in self._book._fileicons: self._book._fileicons[addition] = ImageTk.PhotoImage( self._get_icon(addition) ) icon = self._book._fileicons[addition] else: if type_ not in self._book._fileicons: self._book._fileicons[type_] = ImageTk.PhotoImage( self._get_icon(type_) ) icon = self._book._fileicons[type_] file = addition.split("\\")[-1] tree.insert( parent = node, index = tk.END, iid = addition, tags = (addition, ), text = file, image = icon, values = [file, "", self._book._tabs[drive]._get_size(addition)]) #highlight tree.tag_configure(addition, background = "orange") self.after(self._refresh, self.refresh) def _get_icon(self, PATH): """Gets the icon association for any folder or file in the system Arguments: PATH {str} -- path to file or folder Raises: TypeError -- Thrown if invalid arguments are given Returns: PIL.Image -- PIL Image of the icon at the correct size """ #https://stackoverflow.com/questions/21070423/python-sAaving-accessing-file-extension-icons-and-using-them-in-a-tkinter-progra/52957794#52957794 #https://aecomputervision.blogspot.com/2018/10/getting-icon-association-for-any-file.html return windowsIcons.get_icon(PATH, windowsIcons.SMALL) class DriveBook(ttk.Notebook): _foldericons = {} _fileicons = {} _tabs = {} def __init__(self, parent): ttk.Notebook.__init__(self, parent) self._parent = parent self._draw_tabs() def _draw_tabs(self): self._drive_icons = self._get_icons() #store to attribute so it doesn't get removed by garbage deletion for drive in self._drive_icons: self._tabs[drive[0]] = FileTree(self, drive[0]) if os.path.split(drive[0])[1] != "": self.add(self._tabs[drive[0]], text = "Home", image = drive[1], compound = tk.LEFT) else: self.add(self._tabs[drive[0]], text = drive[0], image = drive[1], compound = tk.LEFT) def _refresh(self): """Checks if a drive as been added or removed. If it has, tabs are refreshed. """ #check if we need to refresh before refreshing if list(map(itemgetter(0), self._drive_icons)) != self._get_drives(): display = [i[0] for i in self._drive_icons] new = self._get_drives() removals = list(set(display).difference(new)) additions = list(set(new).difference(display)) print("removed: ", removals, "added: ", additions) for removal in removals: #delete open nodes that have just been removed self._parent._open = [i for i in self._parent._open if i[0] != removal] #deelte the tab try: self.forget(self._tabs[removal]) except tk.TclError: #TODO: work out why this throws an error when it still works pass #remove from tab dictionary del self._tabs[removal] #reset drive icons self._drive_icons = [i for i in self._drive_icons if i[0] != removal] for addition in additions: self._drive_icons.append([addition, ImageTk.PhotoImage(self._parent._get_icon(addition))]) self._tabs[addition] = FileTree(self, addition) self.add(self._tabs[addition], text = addition, image = self._drive_icons[-1][1], compound = tk.LEFT) def _get_drives(self): return [os.path.expanduser("~")] + win32api.GetLogicalDriveStrings().split('\x00')[:-1] def _get_icons(self): drives = self._get_drives() return [[drive, ImageTk.PhotoImage(self._parent._get_icon(drive))] for drive in drives] def _get_tab_name(self): """Returns the name of the tab which is currently open Returns: int -- the name of the tab that's currently open e.g. C:\\, C:\\Users\\Fred """ return self._get_drives()[self.index(self.select())] class FileTree(tk.Frame): def __init__(self, parent, drive): tk.Frame.__init__(self, parent) self._parent = parent self._command = self._parent._parent._command self._types = self._parent._parent._types self._showhidden = self._parent._parent._showhidden self._tree = ttk.Treeview(self, height = 20, columns = ('path', 'filetype', 'size'), displaycolumns = 'size') self._tree.heading('#0', text = 'Directory', anchor = tk.W) self._tree.heading('size', text = 'Size', anchor = tk.W) self._tree.column('path', width = 180) self._tree.column('size', stretch = 1, width = 48) self._tree.grid(row = 0, column = 0, sticky = 'nsew') sbr_y = ttk.Scrollbar(self, orient = tk.VERTICAL, command = self._tree.yview) sbr_x = ttk.Scrollbar(self, orient = tk.HORIZONTAL, command = self._tree.xview) self._tree['yscroll'] = sbr_y.set self._tree['xscroll'] = sbr_x.set sbr_y.grid(row = 0, column = 1, sticky = 'ns') sbr_x.grid(row = 1, column = 0, sticky = 'ew') self.rowconfigure(0, weight = 1) self.columnconfigure(0, weight = 1) self._populate_path(drive) self._tree.bind('<>', self._on_click) self._tree.bind('<>', self._on_close) if self._parent._parent._rightclick_options != []: self._tree.bind('', self._draw_menu) def _draw_menu(self, event): menu = RightClickMenu(self, self._parent._parent._rightclick_options, self._tree.identify_row(event.y)) try: menu.tk_popup(event.x_root, event.y_root) finally: menu.grab_release() def _on_click(self, event): id = os.path.normpath(self._tree.focus()) #add to the list of open nodes if os.path.isdir(id): #add both the tab name and the path, since the same path could be open in multiple places self._parent._parent._open.append([self._parent._get_tab_name(), id]) #print(self._parent._parent._open) if os.path.isfile(id): self._command(id) else: try: self._populate_path(id) except tk.TclError: #this node has already been opened, so delete children and load again for child in self._tree.get_children(id): self._tree.delete(child) self._populate_path(id) #print("\n clicked: ", self._parent._parent._open) def _on_close(self, event): """Method called when the user closes a node. This must delete the node, and it's children, from the list of open nodes. To do this, a recursive algoritm is used. We have to use this, as opposed to something like os.walk() for preformance. A large folder could have thousands of children. This doesn't 'go deeper' unless the node in the tree is open. Arguments: event {tkinter event} -- tkinter event is used to get the id, """ id = os.path.normpath(self._tree.focus()) drive = self._parent._get_tab_name() def recurse(path): glob_pattern = os.path.join(path, '*') for dir in sorted(glob(glob_pattern), key=os.path.getctime): if os.path.isdir(dir) and self._tree.item(dir, "open"): self._parent._parent._open.remove([drive, dir]) recurse(dir) try: self._parent._parent._open.remove([drive, id]) except ValueError: #already removed by parent pass recurse(id) def _get_size(self, path): size = os.path.getsize(path) if size <= 1024: return "%i bytes" % size else: fmt = matplotlib.ticker.EngFormatter("B", places = 1) return fmt(os.path.getsize(path)) # """Returns the size of a file. From: # https://pyinmyeye.blogspot.co.uk/2012/07/tkinter-multi-column-list-demo.html # Arguments: # path {str} -- Path to file # Returns: # str -- file size in bytes/KB/MB/GB # """ # size = os.path.getsize(path) # KB = 1024.0 # MB = KB * KB # GB = MB * KB # if size >= GB: # return ('{:,.1f} GB').format(size / GB) # elif size >= MB: # return ('{:,.1f} MB').format(size / MB) # elif size >= KB: # return ('{:,.1f} KB').format(size / KB) # else: # return ('{} bytes').format(size) def _populate_path(self, path): #if the tree is blank, write to the root if self._tree.get_children() == (): node = "" else: node = path folders, files = self._get_dirs_in_path(path) for folder in folders: fullpath = os.path.join(path, folder) self._tree.insert( parent = node, index = tk.END, iid = fullpath, tags = (fullpath, ), text = folder, image = self._parent._foldericons[fullpath], values = [folder, "", ""]) #setup a dummy so the '+' appears before it's loaded. Child stuff will be loaded when the user #clicks on the plus, and the dummy will be removed. (or not if there are no files) self._tree.insert(parent = fullpath, index = tk.END, tag = "dummy", text = "No avaliable files") for file in files: fullpath = os.path.join(path, file) name, type_ = os.path.splitext(file) #if the type is a shortcut, get the whole path as a key if type_ == ".lnk" or type_ == ".exe": icon = self._parent._fileicons[fullpath] else: icon = self._parent._fileicons[type_] self._tree.insert( parent = node, index = tk.END, iid = fullpath, tag = fullpath, text = file, image = icon, values = [file, "", self._get_size(fullpath)]) #if there is more than one child, delete every child that isn't a path #and hence delete the dummy children = self._tree.get_children(node) if len(children) > 1: for child in children: if not os.path.exists(child): self._tree.delete(child) def _get_dirs_in_path(self, path): """Returns two lists, the first is a list of all folders in a directory, the second is a list of files. Also loads the icons for these files and folders. For folders, it puts a tk.PhotoImage as the value of the _foldericons dictionary with the key being the full folder path. For files, it uses the _fileicons dictionary, with they key being the filetype, e.g. "mp4". If the file is a shortcut (".lnk") or executable (".exe"), set the whole path as the key, since the icon is different for every file of that type. Arguments: path {str} -- full path to the folder Returns: tuple -- two lists of folders and files. """ def add(p, type_): files.append(p) #if the file is a shortcut, set the key as the whole path if type_ == ".lnk" or type_ == ".exe": if os.path.join(path, p) not in self._parent._fileicons: self._parent._fileicons[os.path.join(path, p)] = ImageTk.PhotoImage( self._parent._parent._get_icon(os.path.join(path, p))) else: if type_ not in self._parent._fileicons: self._parent._fileicons[type_] = ImageTk.PhotoImage( self._parent._parent._get_icon(os.path.join(path, p))) #print(self._parent._fileicons, "\n") files = [] folders = [] try: for p in os.listdir(path): if p.startswith(".") and not self._showhidden: continue if os.path.isfile(os.path.join(path, p)): name, type_ = os.path.splitext(p) if self._types == []: add(p, type_) else: if type_ in self._types: add(p, type_) else: folders.append(p) if os.path.join(path, p) not in self._parent._foldericons: self._parent._foldericons[os.path.join(path, p)] = ImageTk.PhotoImage( self._parent._parent._get_icon(os.path.join(path, p))) except OSError: pass #print("\n\nfolders: ", folders, "\nfiles: ", files) return sorted(folders), sorted(files) #for efficiency, it would be better to instatiate this only once, since now it's instatiated #every time the user right clicks class RightClickMenu(tk.Menu): def __init__(self, parent, options, item): tk.Menu.__init__(self, parent, tearoff = False) self.parent = parent print(item) #only show menu options if the dev selected them #if the dev selected no options, binding is never set #it must be a file for the open option to appear if MENU_OPEN in options and os.path.isfile(item): self.add_command(label = "Open with default program", command = lambda: print("Open")) if MENU_DELETE in options: self.add_command(label = "Delete", image = self.parent._parent._parent._img_cross, compound = tk.LEFT, command = lambda: os.remove(item)) if MENU_CLIPBOARD in options: self.add_command(label = "Copy path to clipboard", image = self.parent._parent._parent._img_clipboard, compound = tk.LEFT, command = lambda: print("clipboard")) def on_click(path): print("click: ", path) # to debug, `python -m tkFileBrowser.fileTreeBrowser` from parent directory if __name__ == "__main__": root = tk.Tk() # browser = TkFileBrowser(root, command = on_click) # browser.pack(side = tk.LEFT) paned = ttk.Panedwindow(root, orient = tk.HORIZONTAL) paned.pack(fill = tk.BOTH, expand = True) browser = TkFileBrowser(paned, command = on_click) # # lbl = tk.Label(root, text = "sneed") # lbl = ttk.Frame(root) # ttk.Label(root, text = "sneed").pack(fill = tk.BOTH, expand = True) # tree = ttk.Treeview(lbl) # tree.pack(fill = tk.BOTH, expand = True) paned.add(browser) btn = ttk.Button(paned, text = "Goto", command = lambda: browser.see(r"C:\Users\eden\Pictures")) paned.add(btn) root.mainloop()