diff options
| author | jwansek <eddie.atten.ea29@gmail.com> | 2025-12-21 16:51:31 +0000 |
|---|---|---|
| committer | jwansek <eddie.atten.ea29@gmail.com> | 2025-12-21 16:51:31 +0000 |
| commit | 33291657255a3c6d57e1cdc269e545a10a6c78c3 (patch) | |
| tree | 87144d3c344a5e411a283688988a58e028669ad5 /fileTreeBrowser.py | |
| parent | 611ae94d709e343af6fa020643732268b8d67229 (diff) | |
| download | tkFileBrowser-33291657255a3c6d57e1cdc269e545a10a6c78c3.tar.gz tkFileBrowser-33291657255a3c6d57e1cdc269e545a10a6c78c3.zip | |
Made into a module, updated
Diffstat (limited to 'fileTreeBrowser.py')
| -rw-r--r-- | fileTreeBrowser.py | 549 |
1 files changed, 549 insertions, 0 deletions
diff --git a/fileTreeBrowser.py b/fileTreeBrowser.py new file mode 100644 index 0000000..162c981 --- /dev/null +++ b/fileTreeBrowser.py @@ -0,0 +1,549 @@ +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 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) + 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('<<TreeviewOpen>>', self._on_click) + self._tree.bind('<<TreeviewClose>>', self._on_close) + if self._parent._parent._rightclick_options != []: + self._tree.bind('<Button-3>', 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): + """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 folders, 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) + + ttk.Button(root, text = "Goto", command = lambda: browser.see(r"C:\Users\eden\Pictures")).pack(side = tk.LEFT) + + root.mainloop()
\ No newline at end of file |
