├── requirements.txt ├── .gitattributes ├── refactor.code-workspace ├── autoscrollbar.py ├── README.md ├── .gitignore ├── canvasimage.py ├── sortimages_multiview.py ├── sortimages.py └── gui.py /requirements.txt: -------------------------------------------------------------------------------- 1 | pyvips 2 | tkinter-tooltip 3 | pillow 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /refactor.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /autoscrollbar.py: -------------------------------------------------------------------------------- 1 | import tkinter as tk 2 | from tkinter import ttk 3 | 4 | class AutoScrollbar(ttk.Scrollbar): 5 | """ A scrollbar that hides itself if it's not needed. Works only for grid geometry manager """ 6 | def set(self, lo, hi): 7 | if float(lo) <= 0.0 and float(hi) >= 1.0: 8 | self.grid_remove() 9 | else: 10 | self.grid() 11 | ttk.Scrollbar.set(self, lo, hi) 12 | 13 | def pack(self, **kw): 14 | raise tk.TclError('Cannot use pack with the widget ' + self.__class__.__name__) 15 | 16 | def place(self, **kw): 17 | raise tk.TclError('Cannot use place with the widget ' + self.__class__.__name__) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple-Image-Sorter 2 | Sorts images into destination files. Written in python. I got really sick of organising my character art and meme folders by hand. 3 | 4 | ## USE 5 | - Make sure all your files that you want to sort are in some kind of directory/folder already, they can be in folders within that folder, the program will scan them all 6 | -- (There is an 'exclusions' function where you can list folder names to ignore. 7 | - Within a new or existing folder, create your new organisational structure, _for example:_ 8 | ``` 9 | Pictures 10 | ├ Family 11 | ├ Holiday 12 | ├ Wedding 13 | ├ My stuff 14 | ├ Misc 15 | ``` 16 | - Select your new root folder ("Pictures", in the above example) as the ``'destination'``, and the folder that contains all the existing pictures you want to sort as the ``source``. Note these *cannot* be the same folder. They must be different structures. 17 | - Press Ready! 18 | Designate images by clicking on them, then assign them to destinations with the corresponding destination button. When you're ready, click "move all" to move everything at once. 19 | 20 | By default the program will only load a portion of the images in the folder for performance reasons. Press the "Add Files" button to make it load another chunk. You can configure how many it adds and loads at once in the program. 21 | - Right-click on Destination Buttons to show which images are assigned to them. (Does not show those that have already been moved) 22 | - Right-click on Thumbnails to show a zoomable full view. You can also **rename** images from this view. 23 | - You can configure the size of thumbnails in prefs.json. Default is 256px. 24 | - The program will save your session automatically on exit with the name of source and destination folders, this WILL overwrite. 25 | - You can save your session manually too with a filename, and load it later to resume where you last left off. 26 | - You can customize hotkeys by opening `prefs.json` and editing the hotkeys entry. There is no GUI editor or hotkeys at this time. 27 | 28 | There are hotkeys and buttons for the folders to sort into, which are essentially your categories, and you can customise hotkeys (though you'll need to restart the program). Hotkeys can be customized in prefs.json 29 | 30 | Thanks to FooBar167 on stackoverflow for the advanced (and memory efficient!) Zoom and Pan tkinter class. Thank you for using this program. 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | #mystuff 127 | prefs.json 128 | data/ 129 | filelog.txt 130 | vipsthumbnail.exe 131 | vipsedit.exe 132 | vips.exe 133 | libglib-2.0-0.dll 134 | libgobject-2.0-0.dll 135 | libvips-42.dll 136 | libvips-cpp-42.dll 137 | session.json 138 | *.json 139 | *.code-workspace 140 | -------------------------------------------------------------------------------- /canvasimage.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Advanced zoom for images of various types from small to huge up to several GB 3 | import math 4 | import warnings 5 | import tkinter as tk 6 | from tkinter import ttk 7 | from PIL import Image, ImageTk 8 | from autoscrollbar import AutoScrollbar 9 | class CanvasImage: 10 | """ Display and zoom image """ 11 | def __init__(self, placeholder, path): 12 | """ Initialize the ImageFrame """ 13 | self.imscale = 1.0 # scale for the canvas image zoom, public for outer classes 14 | self.__delta = 1.3 # zoom magnitude 15 | self.__filter = Image.Resampling.LANCZOS # could be: NEAREST, BILINEAR, BICUBIC and ANTIALIAS 16 | self.__previous_state = 0 # previous state of the keyboard 17 | self.path = path # path to the image, should be public for outer classes 18 | # Create ImageFrame in placeholder widget 19 | self.__imframe = ttk.Frame(placeholder) # placeholder of the ImageFrame object 20 | # Vertical and horizontal scrollbars for canvas 21 | hbar = AutoScrollbar(self.__imframe, orient='horizontal') 22 | vbar = AutoScrollbar(self.__imframe, orient='vertical') 23 | hbar.grid(row=1, column=0, sticky='we') 24 | vbar.grid(row=0, column=1, sticky='ns') 25 | # Create canvas and bind it with scrollbars. Public for outer classes 26 | self.canvas = tk.Canvas(self.__imframe, highlightthickness=0, 27 | xscrollcommand=hbar.set, yscrollcommand=vbar.set) 28 | self.canvas.grid(row=0, column=0, sticky='nswe') 29 | self.canvas.update() # wait till canvas is created 30 | hbar.configure(command=self.__scroll_x) # bind scrollbars to the canvas 31 | vbar.configure(command=self.__scroll_y) 32 | # Bind events to the Canvas 33 | self.canvas.bind('', lambda event: self.__show_image()) # canvas is resized 34 | self.canvas.bind('', self.__move_from) # remember canvas position 35 | self.canvas.bind('', self.__move_to) # move canvas to the new position 36 | self.canvas.bind('', self.__wheel) # zoom for Windows and MacOS, but not Linux 37 | self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll down 38 | self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll up 39 | # Handle keystrokes in idle mode, because program slows down on a weak computers, 40 | # when too many key stroke events in the same time 41 | self.canvas.bind('', lambda event: self.canvas.after_idle(self.__keystroke, event)) 42 | # Decide if this image huge or not 43 | self.__huge = False # huge or not 44 | self.__huge_size = 14000 # define size of the huge image 45 | self.__band_width = 1024 # width of the tile band 46 | Image.MAX_IMAGE_PIXELS = 1000000000 # suppress DecompressionBombError for the big image 47 | with warnings.catch_warnings(): # suppress DecompressionBombWarning 48 | warnings.simplefilter('ignore') 49 | self.__image = Image.open(self.path) # open image, but down't load it 50 | self.imwidth, self.imheight = self.__image.size # public for outer classes 51 | if self.imwidth * self.imheight > self.__huge_size * self.__huge_size and \ 52 | self.__image.tile[0][0] == 'raw': # only raw images could be tiled 53 | self.__huge = True # image is huge 54 | self.__offset = self.__image.tile[0][2] # initial tile offset 55 | self.__tile = [self.__image.tile[0][0], # it have to be 'raw' 56 | [0, 0, self.imwidth, 0], # tile extent (a rectangle) 57 | self.__offset, 58 | self.__image.tile[0][3]] # list of arguments to the decoder 59 | self.__min_side = min(self.imwidth, self.imheight) # get the smaller image side 60 | # Create image pyramid 61 | self.__pyramid = [self.smaller()] if self.__huge else [Image.open(self.path)] 62 | # Set ratio coefficient for image pyramid 63 | self.__ratio = max(self.imwidth, self.imheight) / self.__huge_size if self.__huge else 1.0 64 | self.__curr_img = 0 # current image from the pyramid 65 | self.__scale = self.imscale * self.__ratio # image pyramide scale 66 | self.__reduction = 2 # reduction degree of image pyramid 67 | w, h = self.__pyramid[-1].size 68 | while w > 512 and h > 512: # top pyramid image is around 512 pixels in size 69 | w /= self.__reduction # divide on reduction degree 70 | h /= self.__reduction # divide on reduction degree 71 | self.__pyramid.append(self.__pyramid[-1].resize((int(w), int(h)), self.__filter)) 72 | # Put image into container rectangle and use it to set proper coordinates to the image 73 | self.container = self.canvas.create_rectangle((0, 0, self.imwidth, self.imheight), width=0) 74 | self.__show_image() # show image on the canvas 75 | self.__image.close() 76 | self.canvas.focus_set() # set focus on the canvas 77 | 78 | def smaller(self): 79 | """ Resize image proportionally and return smaller image """ 80 | w1, h1 = float(self.imwidth), float(self.imheight) 81 | w2, h2 = float(self.__huge_size), float(self.__huge_size) 82 | aspect_ratio1 = w1 / h1 83 | aspect_ratio2 = w2 / h2 # it equals to 1.0 84 | if aspect_ratio1 == aspect_ratio2: 85 | image = Image.new('RGB', (int(w2), int(h2))) 86 | k = h2 / h1 # compression ratio 87 | w = int(w2) # band length 88 | elif aspect_ratio1 > aspect_ratio2: 89 | image = Image.new('RGB', (int(w2), int(w2 / aspect_ratio1))) 90 | k = h2 / w1 # compression ratio 91 | w = int(w2) # band length 92 | else: # aspect_ratio1 < aspect_ration2 93 | image = Image.new('RGB', (int(h2 * aspect_ratio1), int(h2))) 94 | k = h2 / h1 # compression ratio 95 | w = int(h2 * aspect_ratio1) # band length 96 | i, j, n = 0, 1, round(0.5 + self.imheight / self.__band_width) 97 | while i < self.imheight: 98 | print('\rOpening image: {j} from {n}'.format(j=j, n=n), end='') 99 | band = min(self.__band_width, self.imheight - i) # width of the tile band 100 | self.__tile[1][3] = band # set band width 101 | self.__tile[2] = self.__offset + self.imwidth * i * 3 # tile offset (3 bytes per pixel) 102 | self.__image.close() 103 | self.__image = Image.open(self.path) # reopen / reset image 104 | self.__image.size = (self.imwidth, band) # set size of the tile band 105 | self.__image.tile = [self.__tile] # set tile 106 | cropped = self.__image.crop((0, 0, self.imwidth, band)) # crop tile band 107 | image.paste(cropped.resize((w, int(band * k)+1), self.__filter), (0, int(i * k))) 108 | i += band 109 | j += 1 110 | print('\r' + 30*' ' + '\r', end='') # hide printed string 111 | return image 112 | 113 | def redraw_figures(self): 114 | """ Dummy function to redraw figures in the children classes """ 115 | pass 116 | 117 | def grid(self, **kw): 118 | """ Put CanvasImage widget on the parent widget """ 119 | self.__imframe.grid(**kw) # place CanvasImage widget on the grid 120 | self.__imframe.grid(sticky='nswe') # make frame container sticky 121 | self.__imframe.rowconfigure(0, weight=1) # make canvas expandable 122 | self.__imframe.columnconfigure(0, weight=1) 123 | 124 | def pack(self, **kw): 125 | """ Exception: cannot use pack with this widget """ 126 | raise Exception('Cannot use pack with the widget ' + self.__class__.__name__) 127 | 128 | def place(self, **kw): 129 | """ Exception: cannot use place with this widget """ 130 | raise Exception('Cannot use place with the widget ' + self.__class__.__name__) 131 | 132 | # noinspection PyUnusedLocal 133 | def __scroll_x(self, *args, **kwargs): 134 | """ Scroll canvas horizontally and redraw the image """ 135 | self.canvas.xview(*args) # scroll horizontally 136 | self.__show_image() # redraw the image 137 | 138 | # noinspection PyUnusedLocal 139 | def __scroll_y(self, *args, **kwargs): 140 | """ Scroll canvas vertically and redraw the image """ 141 | self.canvas.yview(*args) # scroll vertically 142 | self.__show_image() # redraw the image 143 | 144 | def __show_image(self): 145 | """ Show image on the Canvas. Implements correct image zoom almost like in Google Maps """ 146 | box_image = self.canvas.coords(self.container) # get image area 147 | box_canvas = (self.canvas.canvasx(0), # get visible area of the canvas 148 | self.canvas.canvasy(0), 149 | self.canvas.canvasx(self.canvas.winfo_width()), 150 | self.canvas.canvasy(self.canvas.winfo_height())) 151 | box_img_int = tuple(map(int, box_image)) # convert to integer or it will not work properly 152 | # Get scroll region box 153 | box_scroll = [min(box_img_int[0], box_canvas[0]), min(box_img_int[1], box_canvas[1]), 154 | max(box_img_int[2], box_canvas[2]), max(box_img_int[3], box_canvas[3])] 155 | # Horizontal part of the image is in the visible area 156 | if box_scroll[0] == box_canvas[0] and box_scroll[2] == box_canvas[2]: 157 | box_scroll[0] = box_img_int[0] 158 | box_scroll[2] = box_img_int[2] 159 | # Vertical part of the image is in the visible area 160 | if box_scroll[1] == box_canvas[1] and box_scroll[3] == box_canvas[3]: 161 | box_scroll[1] = box_img_int[1] 162 | box_scroll[3] = box_img_int[3] 163 | # Convert scroll region to tuple and to integer 164 | self.canvas.configure(scrollregion=tuple(map(int, box_scroll))) # set scroll region 165 | x1 = max(box_canvas[0] - box_image[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile 166 | y1 = max(box_canvas[1] - box_image[1], 0) 167 | x2 = min(box_canvas[2], box_image[2]) - box_image[0] 168 | y2 = min(box_canvas[3], box_image[3]) - box_image[1] 169 | if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area 170 | if self.__huge and self.__curr_img < 0: # show huge image 171 | h = int((y2 - y1) / self.imscale) # height of the tile band 172 | self.__tile[1][3] = h # set the tile band height 173 | self.__tile[2] = self.__offset + self.imwidth * int(y1 / self.imscale) * 3 174 | self.__image.close() 175 | self.__image = Image.open(self.path) # reopen / reset image 176 | self.__image.size = (self.imwidth, h) # set size of the tile band 177 | self.__image.tile = [self.__tile] 178 | image = self.__image.crop((int(x1 / self.imscale), 0, int(x2 / self.imscale), h)) 179 | else: # show normal image 180 | image = self.__pyramid[max(0, self.__curr_img)].crop( # crop current img from pyramid 181 | (int(x1 / self.__scale), int(y1 / self.__scale), 182 | int(x2 / self.__scale), int(y2 / self.__scale))) 183 | # 184 | imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1)), self.__filter)) 185 | imageid = self.canvas.create_image(max(box_canvas[0], box_img_int[0]), 186 | max(box_canvas[1], box_img_int[1]), 187 | anchor='nw', image=imagetk) 188 | self.canvas.lower(imageid) # set image into background 189 | self.canvas.imagetk = imagetk # keep an extra reference to prevent garbage-collection 190 | 191 | def __move_from(self, event): 192 | """ Remember previous coordinates for scrolling with the mouse """ 193 | self.canvas.scan_mark(event.x, event.y) 194 | 195 | def __move_to(self, event): 196 | """ Drag (move) canvas to the new position """ 197 | self.canvas.scan_dragto(event.x, event.y, gain=1) 198 | self.__show_image() # zoom tile and show it on the canvas 199 | 200 | def outside(self, x, y): 201 | """ Checks if the point (x,y) is outside the image area """ 202 | bbox = self.canvas.coords(self.container) # get image area 203 | if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: 204 | return False # point (x,y) is inside the image area 205 | else: 206 | return True # point (x,y) is outside the image area 207 | 208 | def __wheel(self, event): 209 | """ Zoom with mouse wheel """ 210 | x = self.canvas.canvasx(event.x) # get coordinates of the event on the canvas 211 | y = self.canvas.canvasy(event.y) 212 | if self.outside(x, y): return # zoom only inside image area 213 | scale = 1.0 214 | # Respond to Linux (event.num) or Windows (event.delta) wheel event 215 | if event.num == 5 or event.delta == -120: # scroll down, smaller 216 | if round(self.__min_side * self.imscale) < 30: return # image is less than 30 pixels 217 | self.imscale /= self.__delta 218 | scale /= self.__delta 219 | if event.num == 4 or event.delta == 120: # scroll up, bigger 220 | i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) >> 1 221 | if i < self.imscale: return # 1 pixel is bigger than the visible area 222 | self.imscale *= self.__delta 223 | scale *= self.__delta 224 | # Take appropriate image from the pyramid 225 | k = self.imscale * self.__ratio # temporary coefficient 226 | self.__curr_img = min((-1) * int(math.log(k, self.__reduction)), len(self.__pyramid) - 1) 227 | self.__scale = k * math.pow(self.__reduction, max(0, self.__curr_img)) 228 | # 229 | self.canvas.scale('all', x, y, scale, scale) # rescale all objects 230 | # Redraw some figures before showing image on the screen 231 | self.redraw_figures() # method for child classes 232 | self.__show_image() 233 | 234 | def __keystroke(self, event): 235 | """ Scrolling with the keyboard. 236 | Independent from the language of the keyboard, CapsLock, +, etc. """ 237 | if event.state - self.__previous_state == 4: # means that the Control key is pressed 238 | pass # do nothing if Control key is pressed 239 | else: 240 | self.__previous_state = event.state # remember the last keystroke state 241 | # Up, Down, Left, Right keystrokes 242 | if event.keycode in [68, 39, 102]: # scroll right, keys 'd' or 'Right' 243 | self.__scroll_x('scroll', 1, 'unit', event=event) 244 | elif event.keycode in [65, 37, 100]: # scroll left, keys 'a' or 'Left' 245 | self.__scroll_x('scroll', -1, 'unit', event=event) 246 | elif event.keycode in [87, 38, 104]: # scroll up, keys 'w' or 'Up' 247 | self.__scroll_y('scroll', -1, 'unit', event=event) 248 | elif event.keycode in [83, 40, 98]: # scroll down, keys 's' or 'Down' 249 | self.__scroll_y('scroll', 1, 'unit', event=event) 250 | 251 | def crop(self, bbox): 252 | """ Crop rectangle from the image and return it """ 253 | if self.__huge: # image is huge and not totally in RAM 254 | band = bbox[3] - bbox[1] # width of the tile band 255 | self.__tile[1][3] = band # set the tile height 256 | self.__tile[2] = self.__offset + self.imwidth * bbox[1] * 3 # set offset of the band 257 | self.__image.close() 258 | self.__image = Image.open(self.path) # reopen / reset image 259 | self.__image.size = (self.imwidth, band) # set size of the tile band 260 | self.__image.tile = [self.__tile] 261 | return self.__image.crop((bbox[0], 0, bbox[2], band)) 262 | else: # image is totally in RAM 263 | return self.__pyramid[0].crop(bbox) 264 | 265 | def destroy(self): 266 | """ ImageFrame destructor """ 267 | self.__image.close() 268 | map(lambda i: i.close, self.__pyramid) # close all pyramid images 269 | del self.__pyramid[:] # delete pyramid list 270 | del self.__pyramid # delete pyramid variable 271 | self.canvas.destroy() 272 | self.__imframe.destroy() 273 | 274 | def rescale(self, scale): 275 | """ Rescale the Image without doing anything else """ 276 | self.__scale=scale 277 | self.imscale=scale 278 | 279 | self.canvas.scale('all', self.canvas.winfo_width(), 0, scale, scale) # rescale all objects 280 | self.redraw_figures() 281 | self.__show_image() 282 | -------------------------------------------------------------------------------- /sortimages_multiview.py: -------------------------------------------------------------------------------- 1 | # todo: 2 | # Filename dupicate scanning to prevent collisions 3 | # Check if filename already exists on move. 4 | # implement undo 5 | import os 6 | from shutil import move as shmove 7 | import tkinter as tk 8 | from tkinter.messagebox import askokcancel 9 | import json 10 | import random 11 | from tkinter import filedialog as tkFileDialog 12 | import concurrent.futures as concurrent 13 | import logging 14 | from hashlib import md5 15 | from gui import GUIManager, randomColor 16 | 17 | def import_pyvips(): 18 | "This looks scary, but it just points to where 'import pyvips' can find it's files from" 19 | "To update this module, change vips-dev-8.16 to your new folder name here and in build.bat" 20 | base_path = os.path.dirname(os.path.abspath(__file__)) 21 | vipsbin = os.path.join(base_path, "vips-dev-8.16", "bin") 22 | 23 | if not os.path.exists(vipsbin): 24 | raise FileNotFoundError(f"The directory {vipsbin} does not exist.") 25 | 26 | os.environ['PATH'] = os.pathsep.join((vipsbin, os.environ['PATH'])) 27 | os.add_dll_directory(vipsbin) 28 | import_pyvips() 29 | try: 30 | import pyvips 31 | except Exception as e: 32 | print("Couldn't import pyvips:", e) 33 | 34 | class Imagefile: 35 | path = "" 36 | dest = "" 37 | dupename=False 38 | 39 | def __init__(self, name, path) -> None: 40 | self.name = tk.StringVar() 41 | self.name.set(name) 42 | self.path = path 43 | self.checked = tk.BooleanVar(value=False) 44 | self.moved = False 45 | 46 | def move(self) -> str: 47 | destpath = self.dest 48 | 49 | if destpath != "" and os.path.isdir(destpath): 50 | file_name = self.name.get() 51 | 52 | # Check for name conflicts (source -> destination) 53 | exists_already_in_destination = os.path.exists(os.path.join(destpath, file_name)) 54 | if exists_already_in_destination: 55 | print(f"File {self.name.get()[:30]} already exists at destination. Cancelling move.") 56 | return (f"File {self.name.get()[:30]} already exists at destination. Cancelling move.") # Returns if 1. Would overwrite someone 57 | 58 | try: 59 | new_path = os.path.join(destpath, file_name) 60 | old_path = self.path 61 | 62 | # Throws exception when image is open. 63 | shmove(self.path, new_path) 64 | 65 | self.moved = True 66 | self.show = False 67 | 68 | self.guidata["frame"].configure( 69 | highlightbackground="green", highlightthickness=2) 70 | 71 | self.path = new_path 72 | returnstr = ("Moved:" + self.name.get() + 73 | " -> " + destpath + "\n") 74 | destpath = "" 75 | self.dest = "" 76 | self.hasunmoved = False 77 | return returnstr 78 | except Exception as e: 79 | # Shutil failed. Delete the copy from destination, leaving the original at source. 80 | # This only runs if shutil fails, meaning the image couldn't be deleted from source. 81 | # It is therefore safe to delete the destination copy. 82 | if os.path.exists(new_path) and os.path.exists(old_path): 83 | os.remove(new_path) 84 | print("Shutil failed. Coudln't delete from source, cancelling move (deleting copy from destination)") 85 | return "Shutil failed. Coudln't delete from source, cancelling move (deleting copy from destination)" 86 | else: 87 | logging.warning(f"Error moving/deleting: %s . File: %s {e} {self.name.get()}") 88 | 89 | self.guidata["frame"].configure( 90 | highlightbackground="red", highlightthickness=2) 91 | return ("Error moving: %s . File: %s", e, self.name.get()) 92 | 93 | def setid(self, id): 94 | self.id = id 95 | 96 | def setguidata(self, data): 97 | self.guidata = data 98 | 99 | def setdest(self, dest): 100 | self.dest = dest["path"] 101 | logging.debug("Set destination of %s to %s", 102 | self.name.get(), self.dest) 103 | 104 | 105 | class SortImages: 106 | imagelist = [] 107 | destinations = [] 108 | exclude = [] 109 | thumbnailsize = 256 110 | 111 | def __init__(self) -> None: 112 | self.hasunmoved=False 113 | self.existingnames = set() 114 | self.duplicatenames=[] 115 | self.autosave=True 116 | self.gui = GUIManager(self) 117 | # note, just load the preferences then pass it to the guimanager for processing there 118 | if(os.path.exists("data") and os.path.isdir("data")): 119 | pass 120 | else: 121 | os.mkdir("data") 122 | hotkeys = "" 123 | # todo: replace this with some actual prefs manager that isn't a shittone of ifs 124 | self.threads = 5 125 | try: 126 | with open("prefs.json", "r") as prefsfile: 127 | jdata = prefsfile.read() 128 | jprefs = json.loads(jdata) 129 | if 'hotkeys' in jprefs: 130 | hotkeys = jprefs["hotkeys"] 131 | if 'thumbnailsize' in jprefs: 132 | self.gui.thumbnailsize = int(jprefs["thumbnailsize"]) 133 | self.thumbnailsize = int(jprefs["thumbnailsize"]) 134 | if 'threads' in jprefs: 135 | self.threads = jprefs['threads'] 136 | if "hideonassign" in jprefs: 137 | self.gui.hideonassignvar.set(jprefs["hideonassign"]) 138 | if "hidemoved" in jprefs: 139 | self.gui.hidemovedvar.set(jprefs["hidemoved"]) 140 | if "sortbydate" in jprefs: 141 | self.gui.sortbydatevar.set(jprefs["sortbydate"]) 142 | self.exclude = jprefs["exclude"] 143 | self.gui.sdpEntry.delete(0, len(self.gui.sdpEntry.get())) 144 | self.gui.ddpEntry.delete(0, len(self.gui.ddpEntry.get())) 145 | self.gui.sdpEntry.insert(0, jprefs["srcpath"]) 146 | self.gui.ddpEntry.insert(0, jprefs["despath"]) 147 | if "squaresperpage" in jprefs: 148 | self.gui.squaresperpage.set(int(jprefs["squaresperpage"])) 149 | if "geometry" in jprefs: 150 | self.gui.geometry(jprefs["geometry"]) 151 | if "lastsession" in jprefs: 152 | self.gui.sessionpathvar.set(jprefs['lastsession']) 153 | if "autosavesession" in jprefs: 154 | self.autosave = jprefs['autosave'] 155 | if len(hotkeys) > 1: 156 | self.gui.hotkeys = hotkeys 157 | except Exception as e: 158 | logging.error("Error loading prefs.json, it is possibly corrupt, try deleting it, or else it doesn't exist and will be created upon exiting the program.") 159 | logging.error(e) 160 | self.gui.mainloop() 161 | 162 | def moveall(self): 163 | loglist = [] 164 | for x in self.imagelist: 165 | out = x.move() 166 | if isinstance(out, str): 167 | loglist.append(out) 168 | try: 169 | if len(loglist) > 0: 170 | with open("filelog.txt", "a") as logfile: 171 | logfile.writelines(loglist) 172 | except Exception as e: 173 | logging.error(f"Failed to write filelog.txt: {e}") 174 | self.gui.hidemoved() 175 | 176 | def walk(self, src): 177 | duplicates = self.duplicatenames 178 | existing = self.existingnames 179 | for root, dirs, files in os.walk(src, topdown=True): 180 | dirs[:] = [d for d in dirs if d not in self.exclude] 181 | for name in files: 182 | ext = name.split(".")[len(name.split("."))-1].lower() 183 | if ext == "png" or ext == "gif" or ext == "jpg" or ext == "jpeg" or ext == "bmp" or ext == "pcx" or ext == "tiff" or ext == "webp" or ext == "psd" or ext == "jfif": 184 | imgfile = Imagefile(name, os.path.join(root, name)) 185 | if name in existing: 186 | duplicates.append(imgfile) 187 | imgfile.dupename=True 188 | else: 189 | existing.add(name) 190 | self.imagelist.append(imgfile) 191 | 192 | #Default sorting is based on name. This sorts by date modified. 193 | if self.gui.sortbydatevar.get(): 194 | self.imagelist.sort(key=lambda img: os.path.getmtime(img.path), reverse=True) 195 | 196 | return self.imagelist 197 | 198 | def checkdupefilenames(self, imagelist): 199 | duplicates: list[Imagefile] = [] 200 | existing: set[str] = set() 201 | 202 | for item in imagelist: 203 | if item.name.get() in existing: 204 | duplicates.append(item) 205 | item.dupename=True 206 | else: 207 | existing.add(item.name) 208 | return duplicates 209 | 210 | def setDestination(self, *args): 211 | self.hasunmoved = True 212 | marked = [] 213 | dest = args[0] 214 | try: 215 | wid = args[1].widget 216 | except AttributeError: 217 | wid = args[1]["widget"] 218 | if isinstance(wid, tk.Entry): 219 | pass 220 | else: 221 | for x in self.imagelist: 222 | if x.checked.get(): 223 | marked.append(x) 224 | for obj in marked: 225 | obj.setdest(dest) 226 | obj.guidata["frame"]['background'] = dest['color'] 227 | obj.guidata["canvas"]['background'] = dest['color'] 228 | obj.checked.set(False) 229 | self.gui.hideassignedsquare(marked) 230 | 231 | def savesession(self,asksavelocation): 232 | if asksavelocation: 233 | filet=[("Javascript Object Notation","*.json")] 234 | savelocation=tkFileDialog.asksaveasfilename(confirmoverwrite=True,defaultextension=filet,filetypes=filet,initialdir=os.getcwd(),initialfile=self.gui.sessionpathvar.get()) 235 | else: 236 | savelocation = self.gui.sessionpathvar.get() 237 | if len(self.imagelist) > 0: 238 | imagesavedata = [] 239 | for obj in self.imagelist: 240 | if hasattr(obj, 'thumbnail'): 241 | thumb = obj.thumbnail 242 | else: 243 | thumb = "" 244 | imagesavedata.append({ 245 | "name": obj.name.get(), 246 | "path": obj.path, 247 | "dest": obj.dest, 248 | "checked": obj.checked.get(), 249 | "moved": obj.moved, 250 | "thumbnail": thumb, 251 | "dupename":obj.dupename 252 | }) 253 | save = {"dest": self.ddp, "source": self.sdp, 254 | "imagelist": imagesavedata,"thumbnailsize":self.thumbnailsize,'existingnames':list(self.existingnames)} 255 | with open(savelocation, "w+") as savef: 256 | json.dump(save, savef) 257 | 258 | def loadsession(self): 259 | sessionpath = self.gui.sessionpathvar.get() 260 | if os.path.exists(sessionpath) and os.path.isfile(sessionpath): 261 | with open(sessionpath, "r") as savef: 262 | sdata = savef.read() 263 | savedata = json.loads(sdata) 264 | gui = self.gui 265 | self.ddp = savedata['dest'] 266 | self.sdp = savedata['source'] 267 | self.setup(savedata['dest']) 268 | if 'existingnames' in savedata: 269 | self.existingnames = set(savedata['existingnames']) 270 | for o in savedata['imagelist']: 271 | if os.path.exists(o['path']): 272 | n = Imagefile(o['name'], o['path']) 273 | n.checked.set(o['checked']) 274 | n.moved = o['moved'] 275 | n.thumbnail = o['thumbnail'] 276 | n.dupename=o['dupename'] 277 | n.dest=o['dest'] 278 | self.imagelist.append(n) 279 | self.thumbnailsize=savedata['thumbnailsize'] 280 | self.gui.thumbnailsize=savedata['thumbnailsize'] 281 | listmax = min(gui.squaresperpage.get(), len(self.imagelist)) 282 | sublist = self.imagelist[0:listmax] 283 | gui.displaygrid(self.imagelist, range(0, min(gui.squaresperpage.get(),listmax))) 284 | gui.guisetup(self.destinations) 285 | gui.hidemoved() 286 | gui.hideassignedsquare(sublist) 287 | else: 288 | logging.error("No Last Session!") 289 | 290 | def validate(self, gui): 291 | samepath = (gui.sdpEntry.get() == gui.ddpEntry.get()) 292 | if((os.path.isdir(gui.sdpEntry.get())) and (os.path.isdir(gui.ddpEntry.get())) and not samepath): 293 | self.sdp = gui.sdpEntry.get() 294 | self.ddp = gui.ddpEntry.get() 295 | logging.info("main class setup") 296 | self.setup(self.ddp) 297 | logging.info("GUI setup") 298 | gui.guisetup(self.destinations) 299 | gui.sessionpathvar.set(os.path.basename( 300 | self.sdp)+"-"+os.path.basename(self.ddp)+".json") 301 | logging.info("displaying first image grid") 302 | self.walk(self.sdp) 303 | listmax = min(gui.squaresperpage.get(), len(self.imagelist)) 304 | sublist = self.imagelist[0:listmax] 305 | self.generatethumbnails(sublist) 306 | gui.displaygrid(self.imagelist, range(0, min(len(self.imagelist), gui.squaresperpage.get()))) 307 | elif gui.sdpEntry.get() == gui.ddpEntry.get(): 308 | gui.sdpEntry.delete(0, len(gui.sdpEntry.get())) 309 | gui.ddpEntry.delete(0, len(gui.ddpEntry.get())) 310 | gui.sdpEntry.insert(0, "PATHS CANNOT BE SAME") 311 | gui.ddpEntry.insert(0, "PATHS CANNOT BE SAME") 312 | else: 313 | gui.sdpEntry.delete(0, len(gui.sdpEntry.get())) 314 | gui.ddpEntry.delete(0, len(gui.ddpEntry.get())) 315 | gui.sdpEntry.insert(0, "ERROR INVALID PATH") 316 | gui.ddpEntry.insert(0, "ERROR INVALID PATH") 317 | 318 | def setup(self, dest): 319 | # scan the destination 320 | self.destinations = [] 321 | self.destinationsraw = [] 322 | with os.scandir(dest) as it: 323 | for entry in it: 324 | if entry.is_dir(): 325 | random.seed(entry.name) 326 | self.destinations.append( 327 | {'name': entry.name, 'path': entry.path, 'color': randomColor()}) 328 | self.destinationsraw.append(entry.path) 329 | 330 | def makethumb(self, imagefile): 331 | im = pyvips.Image.new_from_file(imagefile.path,) 332 | hash = md5() 333 | hash.update(im.write_to_memory()) 334 | imagefile.setid(hash.hexdigest()) 335 | thumbpath = os.path.join("data", imagefile.id+os.extsep+"jpg") 336 | if os.path.exists(thumbpath): 337 | imagefile.thumbnail = thumbpath 338 | else: 339 | try: 340 | im = pyvips.Image.thumbnail(imagefile.path, self.thumbnailsize) 341 | im.write_to_file(thumbpath) 342 | imagefile.thumbnail = thumbpath 343 | except Exception as e: 344 | logging.error("Error:: %s", e) 345 | 346 | def generatethumbnails(self, images): 347 | logging.info("md5 hashing %s files", len(images)) 348 | with concurrent.ThreadPoolExecutor(max_workers=self.threads) as executor: 349 | executor.map(self.makethumb, images) 350 | logging.info("Finished making thumbnails") 351 | 352 | def clear(self, *args): 353 | if askokcancel("Confirm", "Really clear your selection?"): 354 | for x in self.imagelist: 355 | x.checked.set(False) 356 | 357 | 358 | # Run Program 359 | if __name__ == '__main__': 360 | format = "%(asctime)s: %(message)s" 361 | logging.basicConfig( 362 | format=format, level=logging.WARNING, datefmt="%H:%M:%S") 363 | mainclass = SortImages() 364 | -------------------------------------------------------------------------------- /sortimages.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sys import exit 3 | from tkinter import messagebox 4 | from shutil import move as shmove 5 | import tkinter as tk 6 | import tkinter.scrolledtext as tkst 7 | from PIL import Image,ImageTk 8 | from functools import partial 9 | from math import floor 10 | import json 11 | import atexit 12 | import random 13 | from math import floor,sqrt 14 | from tkinter import filedialog as tkFileDialog 15 | import tkinter.font as tkfont 16 | from collections import deque 17 | from tkinter import PanedWindow 18 | 19 | #I am aware this code is fingerpaint-tier 20 | 21 | # -*- coding: utf-8 -*- 22 | # Advanced zoom for images of various types from small to huge up to several GB 23 | import math 24 | import warnings 25 | from tkinter import ttk 26 | from PIL import Image, ImageTk 27 | 28 | class AutoScrollbar(ttk.Scrollbar): 29 | """ A scrollbar that hides itself if it's not needed. Works only for grid geometry manager """ 30 | def set(self, lo, hi): 31 | if float(lo) <= 0.0 and float(hi) >= 1.0: 32 | self.grid_remove() 33 | else: 34 | self.grid() 35 | ttk.Scrollbar.set(self, lo, hi) 36 | 37 | def pack(self, **kw): 38 | raise tk.TclError('Cannot use pack with the widget ' + self.__class__.__name__) 39 | 40 | def place(self, **kw): 41 | raise tk.TclError('Cannot use place with the widget ' + self.__class__.__name__) 42 | 43 | class CanvasImage: 44 | """ Display and zoom image """ 45 | def __init__(self, placeholder, path): 46 | """ Initialize the ImageFrame """ 47 | self.imscale = 1.0 # scale for the canvas image zoom, public for outer classes 48 | self.__delta = 1.3 # zoom magnitude 49 | self.__filter = Image.LANCZOS # could be: NEAREST, BILINEAR, BICUBIC and ANTIALIAS 50 | self.__previous_state = 0 # previous state of the keyboard 51 | self.path = path # path to the image, should be public for outer classes 52 | # Create ImageFrame in placeholder widget 53 | self.__imframe = ttk.Frame(placeholder) # placeholder of the ImageFrame object 54 | # Vertical and horizontal scrollbars for canvas 55 | hbar = AutoScrollbar(self.__imframe, orient='horizontal') 56 | vbar = AutoScrollbar(self.__imframe, orient='vertical') 57 | hbar.grid(row=1, column=0, sticky='we') 58 | vbar.grid(row=0, column=1, sticky='ns') 59 | # Create canvas and bind it with scrollbars. Public for outer classes 60 | self.canvas = tk.Canvas(self.__imframe, highlightthickness=0, 61 | xscrollcommand=hbar.set, yscrollcommand=vbar.set) 62 | self.canvas.grid(row=0, column=0, sticky='nswe') 63 | self.canvas.update() # wait till canvas is created 64 | hbar.configure(command=self.__scroll_x) # bind scrollbars to the canvas 65 | vbar.configure(command=self.__scroll_y) 66 | # Bind events to the Canvas 67 | self.canvas.bind('', lambda event: self.__show_image()) # canvas is resized 68 | self.canvas.bind('', self.__move_from) # remember canvas position 69 | self.canvas.bind('', self.__move_to) # move canvas to the new position 70 | self.canvas.bind('', self.__wheel) # zoom for Windows and MacOS, but not Linux 71 | self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll down 72 | self.canvas.bind('', self.__wheel) # zoom for Linux, wheel scroll up 73 | # Handle keystrokes in idle mode, because program slows down on a weak computers, 74 | # when too many key stroke events in the same time 75 | self.canvas.bind('', lambda event: self.canvas.after_idle(self.__keystroke, event)) 76 | # Decide if this image huge or not 77 | self.__huge = False # huge or not 78 | self.__huge_size = 14000 # define size of the huge image 79 | self.__band_width = 1024 # width of the tile band 80 | Image.MAX_IMAGE_PIXELS = 1000000000 # suppress DecompressionBombError for the big image 81 | with warnings.catch_warnings(): # suppress DecompressionBombWarning 82 | warnings.simplefilter('ignore') 83 | self.__image = Image.open(self.path) # open image, but down't load it 84 | self.imwidth, self.imheight = self.__image.size # public for outer classes 85 | if self.imwidth * self.imheight > self.__huge_size * self.__huge_size and \ 86 | self.__image.tile[0][0] == 'raw': # only raw images could be tiled 87 | self.__huge = True # image is huge 88 | self.__offset = self.__image.tile[0][2] # initial tile offset 89 | self.__tile = [self.__image.tile[0][0], # it have to be 'raw' 90 | [0, 0, self.imwidth, 0], # tile extent (a rectangle) 91 | self.__offset, 92 | self.__image.tile[0][3]] # list of arguments to the decoder 93 | self.__min_side = min(self.imwidth, self.imheight) # get the smaller image side 94 | # Create image pyramid 95 | self.__pyramid = [self.smaller()] if self.__huge else [Image.open(self.path)] 96 | # Set ratio coefficient for image pyramid 97 | self.__ratio = max(self.imwidth, self.imheight) / self.__huge_size if self.__huge else 1.0 98 | self.__curr_img = 0 # current image from the pyramid 99 | self.__scale = self.imscale * self.__ratio # image pyramide scale 100 | self.__reduction = 2 # reduction degree of image pyramid 101 | w, h = self.__pyramid[-1].size 102 | while w > 512 and h > 512: # top pyramid image is around 512 pixels in size 103 | w /= self.__reduction # divide on reduction degree 104 | h /= self.__reduction # divide on reduction degree 105 | self.__pyramid.append(self.__pyramid[-1].resize((int(w), int(h)), self.__filter)) 106 | # Put image into container rectangle and use it to set proper coordinates to the image 107 | self.container = self.canvas.create_rectangle((0, 0, self.imwidth, self.imheight), width=0) 108 | self.__show_image() # show image on the canvas 109 | self.__image.close() 110 | self.canvas.focus_set() # set focus on the canvas 111 | 112 | def smaller(self): 113 | """ Resize image proportionally and return smaller image """ 114 | w1, h1 = float(self.imwidth), float(self.imheight) 115 | w2, h2 = float(self.__huge_size), float(self.__huge_size) 116 | aspect_ratio1 = w1 / h1 117 | aspect_ratio2 = w2 / h2 # it equals to 1.0 118 | if aspect_ratio1 == aspect_ratio2: 119 | image = Image.new('RGB', (int(w2), int(h2))) 120 | k = h2 / h1 # compression ratio 121 | w = int(w2) # band length 122 | elif aspect_ratio1 > aspect_ratio2: 123 | image = Image.new('RGB', (int(w2), int(w2 / aspect_ratio1))) 124 | k = h2 / w1 # compression ratio 125 | w = int(w2) # band length 126 | else: # aspect_ratio1 < aspect_ration2 127 | image = Image.new('RGB', (int(h2 * aspect_ratio1), int(h2))) 128 | k = h2 / h1 # compression ratio 129 | w = int(h2 * aspect_ratio1) # band length 130 | i, j, n = 0, 1, round(0.5 + self.imheight / self.__band_width) 131 | while i < self.imheight: 132 | print('\rOpening image: {j} from {n}'.format(j=j, n=n), end='') 133 | band = min(self.__band_width, self.imheight - i) # width of the tile band 134 | self.__tile[1][3] = band # set band width 135 | self.__tile[2] = self.__offset + self.imwidth * i * 3 # tile offset (3 bytes per pixel) 136 | self.__image.close() 137 | self.__image = Image.open(self.path) # reopen / reset image 138 | self.__image.size = (self.imwidth, band) # set size of the tile band 139 | self.__image.tile = [self.__tile] # set tile 140 | cropped = self.__image.crop((0, 0, self.imwidth, band)) # crop tile band 141 | image.paste(cropped.resize((w, int(band * k)+1), self.__filter), (0, int(i * k))) 142 | i += band 143 | j += 1 144 | print('\r' + 30*' ' + '\r', end='') # hide printed string 145 | return image 146 | 147 | def redraw_figures(self): 148 | """ Dummy function to redraw figures in the children classes """ 149 | pass 150 | 151 | def grid(self, **kw): 152 | """ Put CanvasImage widget on the parent widget """ 153 | self.__imframe.grid(**kw) # place CanvasImage widget on the grid 154 | self.__imframe.grid(sticky='nswe') # make frame container sticky 155 | self.__imframe.rowconfigure(0, weight=1) # make canvas expandable 156 | self.__imframe.columnconfigure(0, weight=1) 157 | 158 | def pack(self, **kw): 159 | """ Exception: cannot use pack with this widget """ 160 | raise Exception('Cannot use pack with the widget ' + self.__class__.__name__) 161 | 162 | def place(self, **kw): 163 | """ Exception: cannot use place with this widget """ 164 | raise Exception('Cannot use place with the widget ' + self.__class__.__name__) 165 | 166 | # noinspection PyUnusedLocal 167 | def __scroll_x(self, *args, **kwargs): 168 | """ Scroll canvas horizontally and redraw the image """ 169 | self.canvas.xview(*args) # scroll horizontally 170 | self.__show_image() # redraw the image 171 | 172 | # noinspection PyUnusedLocal 173 | def __scroll_y(self, *args, **kwargs): 174 | """ Scroll canvas vertically and redraw the image """ 175 | self.canvas.yview(*args) # scroll vertically 176 | self.__show_image() # redraw the image 177 | 178 | def __show_image(self): 179 | """ Show image on the Canvas. Implements correct image zoom almost like in Google Maps """ 180 | box_image = self.canvas.coords(self.container) # get image area 181 | box_canvas = (self.canvas.canvasx(0), # get visible area of the canvas 182 | self.canvas.canvasy(0), 183 | self.canvas.canvasx(self.canvas.winfo_width()), 184 | self.canvas.canvasy(self.canvas.winfo_height())) 185 | box_img_int = tuple(map(int, box_image)) # convert to integer or it will not work properly 186 | # Get scroll region box 187 | box_scroll = [min(box_img_int[0], box_canvas[0]), min(box_img_int[1], box_canvas[1]), 188 | max(box_img_int[2], box_canvas[2]), max(box_img_int[3], box_canvas[3])] 189 | # Horizontal part of the image is in the visible area 190 | if box_scroll[0] == box_canvas[0] and box_scroll[2] == box_canvas[2]: 191 | box_scroll[0] = box_img_int[0] 192 | box_scroll[2] = box_img_int[2] 193 | # Vertical part of the image is in the visible area 194 | if box_scroll[1] == box_canvas[1] and box_scroll[3] == box_canvas[3]: 195 | box_scroll[1] = box_img_int[1] 196 | box_scroll[3] = box_img_int[3] 197 | # Convert scroll region to tuple and to integer 198 | self.canvas.configure(scrollregion=tuple(map(int, box_scroll))) # set scroll region 199 | x1 = max(box_canvas[0] - box_image[0], 0) # get coordinates (x1,y1,x2,y2) of the image tile 200 | y1 = max(box_canvas[1] - box_image[1], 0) 201 | x2 = min(box_canvas[2], box_image[2]) - box_image[0] 202 | y2 = min(box_canvas[3], box_image[3]) - box_image[1] 203 | if int(x2 - x1) > 0 and int(y2 - y1) > 0: # show image if it in the visible area 204 | if self.__huge and self.__curr_img < 0: # show huge image 205 | h = int((y2 - y1) / self.imscale) # height of the tile band 206 | self.__tile[1][3] = h # set the tile band height 207 | self.__tile[2] = self.__offset + self.imwidth * int(y1 / self.imscale) * 3 208 | self.__image.close() 209 | self.__image = Image.open(self.path) # reopen / reset image 210 | self.__image.size = (self.imwidth, h) # set size of the tile band 211 | self.__image.tile = [self.__tile] 212 | image = self.__image.crop((int(x1 / self.imscale), 0, int(x2 / self.imscale), h)) 213 | else: # show normal image 214 | image = self.__pyramid[max(0, self.__curr_img)].crop( # crop current img from pyramid 215 | (int(x1 / self.__scale), int(y1 / self.__scale), 216 | int(x2 / self.__scale), int(y2 / self.__scale))) 217 | # 218 | imagetk = ImageTk.PhotoImage(image.resize((int(x2 - x1), int(y2 - y1)), self.__filter)) 219 | imageid = self.canvas.create_image(max(box_canvas[0], box_img_int[0]), 220 | max(box_canvas[1], box_img_int[1]), 221 | anchor='nw', image=imagetk) 222 | self.canvas.lower(imageid) # set image into background 223 | self.canvas.imagetk = imagetk # keep an extra reference to prevent garbage-collection 224 | 225 | def __move_from(self, event): 226 | """ Remember previous coordinates for scrolling with the mouse """ 227 | self.canvas.scan_mark(event.x, event.y) 228 | 229 | def __move_to(self, event): 230 | """ Drag (move) canvas to the new position """ 231 | self.canvas.scan_dragto(event.x, event.y, gain=1) 232 | self.__show_image() # zoom tile and show it on the canvas 233 | 234 | def outside(self, x, y): 235 | """ Checks if the point (x,y) is outside the image area """ 236 | bbox = self.canvas.coords(self.container) # get image area 237 | if bbox[0] < x < bbox[2] and bbox[1] < y < bbox[3]: 238 | return False # point (x,y) is inside the image area 239 | else: 240 | return True # point (x,y) is outside the image area 241 | 242 | def __wheel(self, event): 243 | """ Zoom with mouse wheel """ 244 | x = self.canvas.canvasx(event.x) # get coordinates of the event on the canvas 245 | y = self.canvas.canvasy(event.y) 246 | if self.outside(x, y): return # zoom only inside image area 247 | scale = 1.0 248 | # Respond to Linux (event.num) or Windows (event.delta) wheel event 249 | if event.num == 5 or event.delta == -120: # scroll down, smaller 250 | if round(self.__min_side * self.imscale) < 30: return # image is less than 30 pixels 251 | self.imscale /= self.__delta 252 | scale /= self.__delta 253 | if event.num == 4 or event.delta == 120: # scroll up, bigger 254 | i = min(self.canvas.winfo_width(), self.canvas.winfo_height()) >> 1 255 | if i < self.imscale: return # 1 pixel is bigger than the visible area 256 | self.imscale *= self.__delta 257 | scale *= self.__delta 258 | # Take appropriate image from the pyramid 259 | k = self.imscale * self.__ratio # temporary coefficient 260 | self.__curr_img = min((-1) * int(math.log(k, self.__reduction)), len(self.__pyramid) - 1) 261 | self.__scale = k * math.pow(self.__reduction, max(0, self.__curr_img)) 262 | # 263 | self.canvas.scale('all', x, y, scale, scale) # rescale all objects 264 | # Redraw some figures before showing image on the screen 265 | self.redraw_figures() # method for child classes 266 | self.__show_image() 267 | 268 | def __keystroke(self, event): 269 | """ Scrolling with the keyboard. 270 | Independent from the language of the keyboard, CapsLock, +, etc. """ 271 | if event.state - self.__previous_state == 4: # means that the Control key is pressed 272 | pass # do nothing if Control key is pressed 273 | else: 274 | self.__previous_state = event.state # remember the last keystroke state 275 | # Up, Down, Left, Right keystrokes 276 | if event.keycode in [68, 39, 102]: # scroll right, keys 'd' or 'Right' 277 | self.__scroll_x('scroll', 1, 'unit', event=event) 278 | elif event.keycode in [65, 37, 100]: # scroll left, keys 'a' or 'Left' 279 | self.__scroll_x('scroll', -1, 'unit', event=event) 280 | elif event.keycode in [87, 38, 104]: # scroll up, keys 'w' or 'Up' 281 | self.__scroll_y('scroll', -1, 'unit', event=event) 282 | elif event.keycode in [83, 40, 98]: # scroll down, keys 's' or 'Down' 283 | self.__scroll_y('scroll', 1, 'unit', event=event) 284 | 285 | def crop(self, bbox): 286 | """ Crop rectangle from the image and return it """ 287 | if self.__huge: # image is huge and not totally in RAM 288 | band = bbox[3] - bbox[1] # width of the tile band 289 | self.__tile[1][3] = band # set the tile height 290 | self.__tile[2] = self.__offset + self.imwidth * bbox[1] * 3 # set offset of the band 291 | self.__image.close() 292 | self.__image = Image.open(self.path) # reopen / reset image 293 | self.__image.size = (self.imwidth, band) # set size of the tile band 294 | self.__image.tile = [self.__tile] 295 | return self.__image.crop((bbox[0], 0, bbox[2], band)) 296 | else: # image is totally in RAM 297 | return self.__pyramid[0].crop(bbox) 298 | 299 | def destroy(self): 300 | """ ImageFrame destructor """ 301 | self.__image.close() 302 | map(lambda i: i.close, self.__pyramid) # close all pyramid images 303 | del self.__pyramid[:] # delete pyramid list 304 | del self.__pyramid # delete pyramid variable 305 | self.canvas.destroy() 306 | self.__imframe.destroy() 307 | 308 | def rescale(self, scale): 309 | """ Rescale the Image without doing anything else """ 310 | self.__scale=scale 311 | self.imscale=scale 312 | 313 | self.canvas.scale('all', self.canvas.winfo_width(), 0, scale, scale) # rescale all objects 314 | self.redraw_figures() 315 | self.__show_image() 316 | 317 | 318 | tkroot = tk.Tk() 319 | toppane = tk.Frame(tkroot) 320 | style = ttk.Style() 321 | style.theme_use('classic') 322 | destinations = [] 323 | tkroot.geometry(str(tkroot.winfo_screenwidth())+"x"+str(tkroot.winfo_screenheight()-120)) 324 | tkroot.geometry("+0+60") 325 | buttons = [] 326 | imagelist= deque() 327 | imgiterator = 0 328 | guirow=1 329 | guicol=0 330 | sdp="" 331 | ddp="" 332 | exclude=[] 333 | columns = 1 334 | 335 | #more guisetup 336 | ####### 337 | #textout=tkst.ScrolledText(text_frame) 338 | #textout.config(state=tk.DISABLED) 339 | 340 | #def tklog(instring): 341 | #replaced print but I can't get the positoning correct 342 | #global #textout 343 | #textout.config(state=tk.NORMAL) 344 | #textout.insert(tk.INSERT,"\n"+instring) 345 | #textout.config(state=tk.DISABLED) 346 | 347 | def randomColor(): 348 | color = '#' 349 | hexletters = '0123456789ABCDEF'; 350 | for i in range(0,6): 351 | color += hexletters[floor(random.random()*16)] 352 | return color 353 | 354 | def luminance(hexin): 355 | color = tuple(int(hexin.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) 356 | r = color[0] 357 | g = color[1] 358 | b = color[2] 359 | hsp = sqrt( 360 | 0.299 * (r**2) + 361 | 0.587 * (g**2) + 362 | 0.114 * (b**2) 363 | ) 364 | if hsp >115.6: 365 | return 'light' 366 | else: 367 | return 'dark' 368 | 369 | 370 | 371 | def validate(): 372 | global sdp 373 | global ddp 374 | samepath = (sdpEntry.get() == ddpEntry.get()) 375 | if((os.path.isdir(sdpEntry.get())) and (os.path.isdir(ddpEntry.get())) and not samepath): 376 | sdp=sdpEntry.get() 377 | ddp=ddpEntry.get() 378 | setup(sdp,ddp) 379 | guisetup() 380 | displayimage() 381 | saveonexit() 382 | elif sdpEntry.get() == ddpEntry.get(): 383 | sdpEntry.delete(0,len(sdpEntry.get())) 384 | ddpEntry.delete(0,len(ddpEntry.get())) 385 | sdpEntry.insert(0,"PATHS CANNOT BE SAME") 386 | ddpEntry.insert(0,"PATHS CANNOT BE SAME") 387 | else: 388 | sdpEntry.delete(0,len(sdpEntry.get())) 389 | ddpEntry.delete(0,len(ddpEntry.get())) 390 | sdpEntry.insert(0,"ERROR INVALID PATH") 391 | ddpEntry.insert(0,"ERROR INVALID PATH") 392 | 393 | 394 | def setup(src,dest): 395 | global imagelist 396 | global destinations 397 | global imgiterator 398 | global exclude 399 | destinations = [] 400 | imgiterator = 0 401 | #scan the destination 402 | if src[len(src)-1]=="\\":#trim trailing slashes 403 | src=src[:-1] 404 | if dest[len(dest)-1]=="\\": 405 | dest=dest[:-1] 406 | with os.scandir(dest) as it: 407 | for entry in it: 408 | if entry.is_dir(): 409 | destinations.append({'name': entry.name,'path': entry.path}) 410 | destinations.append({'name': "SKIP"}) 411 | destinations.append({'name': "BACK"}) 412 | #walk the source files 413 | for root,dirs,files in os.walk(src,topdown=True): 414 | dirs[:] = [d for d in dirs if d not in exclude] 415 | for name in files: 416 | ext = os.path.splitext(name)[1].lstrip(".") 417 | if ext == "png" or ext == "gif" or ext == "jpg" or ext == "jpeg" or ext == "bmp" or ext == "pcx" or ext == "tiff" or ext=="webp" or ext=="psd" or ext=="jfif": 418 | imagelist.append({"name":name, "path":os.path.join(root,name), "dest":""}) 419 | 420 | def disable_event(): 421 | pass 422 | 423 | tkroot.columnconfigure(0, weight=1) 424 | tkroot.rowconfigure(0,weight=1) 425 | toppane.columnconfigure(1,weight=3) 426 | toppane.rowconfigure(0,weight=1) 427 | toppane.grid(row=0,column=0,sticky="NSEW",rowspan=200) 428 | guiframe=tk.Frame(toppane) 429 | guiframe.grid(row=0,column=0,sticky="NS") 430 | 431 | hotkeys = "123456qwerty7890uiop[asdfghjkl;zxcvbnm,.!@#$%^QWERT&*()_UIOPASDFGHJKLZXCVBNM<>" 432 | 433 | 434 | panel = tk.Label(guiframe, wraplength=300, justify="left", text="""Select a source directory to search for images in above. 435 | The program will find all png, gif, jpg, bmp, pcx, tiff, Webp, and psds. It can has as many sub-folders as you like, the program will scan them all (except exclusions). 436 | Enter a root folder to sort to for the "Destination field" too. The destination directory MUST have sub folders, since those are the folders that you will be sorting to. 437 | It is reccomended that the folder names are not super long. You can always rename them later if you desire longer names. Exclusions let you ignore folder names. They are saved (unless you delete prefs.json). Remember that it's one per line, no commas or anything. 438 | You can change the hotkeys in prefs.json, just type a string of letters and numbers and it'll use that. It differentiates between lower and upper case (anything that uses shift), but not numpad. 439 | Thanks to FooBar167 on stackoverflow for the advanced (and memory efficient!) Zoom and Pan tkinter class. 440 | You can use arrow keys or click and drag to pan the image. Mouse Wheel Zooms the image. 441 | Thanks you for using this program!""") 442 | panel.grid(row=12,column=0,columnspan=200, sticky="NSEW") 443 | rescalemode=tk.BooleanVar(value=True) 444 | rescalecheckbox = ttk.Checkbutton(guiframe,text="Auto-Zoom Images",variable=rescalemode,onvalue=True,offvalue=False) 445 | rescalecheckbox.grid(row=3,column=0,sticky="E") 446 | 447 | tkroot.columnconfigure(0, weight=1) 448 | buttonframe = tk.Frame(guiframe) 449 | buttonframe.grid(column=0,row=4,sticky="NSEW",rowspan=2,columnspan=3) 450 | buttonframe.columnconfigure(0,weight=1) 451 | buttonframe.columnconfigure(1,weight=1) 452 | buttonframe.columnconfigure(2,weight=1) 453 | 454 | def movefile(dest,event=None): 455 | global imgiterator 456 | global imageframe 457 | imageframe.canvas.delete("all") 458 | imageframe.imagetk=None 459 | shmove(imagelist[imgiterator]["path"],os.path.join(dest,imagelist[imgiterator]["name"])) 460 | print("Moved: " + imagelist[imgiterator]["name"] + " to " +dest) 461 | imagelist[imgiterator]["dest"] = os.path.join(dest,imagelist[imgiterator]["name"]) 462 | imgiterator+=1 463 | displayimage() 464 | 465 | 466 | def skip(event=None): 467 | global imgiterator 468 | imgiterator+=1 469 | displayimage() 470 | 471 | def guisetup(): 472 | global guicol 473 | global guirow 474 | global panel 475 | global sdpEntry 476 | global ddpEntry 477 | global panel 478 | global buttonframe 479 | global hotkeys 480 | global columns 481 | for x in buttons: 482 | x.destroy() #clear the gui 483 | panel.destroy() 484 | guirow=1 485 | guicol=0 486 | itern=0 487 | smallfont = tkfont.Font( family='Helvetica',size=10) 488 | columns = 1 489 | if len(destinations) > int((guiframe.winfo_height()/35)-2): 490 | columns=2 491 | buttonframe.columnconfigure(1, weight=1) 492 | for x in destinations: 493 | if x['name'] != "SKIP" and x['name'] != "BACK": 494 | if(itern < len(hotkeys)): 495 | newbut = tk.Button(buttonframe, text=hotkeys[itern] +": "+ x['name'], command= partial(movefile,x['path']),anchor="w", wraplength=(guiframe.winfo_width()/columns)-1) 496 | random.seed(x['name']) 497 | tkroot.bind_all(hotkeys[itern],partial(movefile,x['path'])) 498 | color = randomColor() 499 | fg = 'white' 500 | if luminance(color) == 'light': 501 | fg = "black" 502 | newbut.configure(bg =color, fg =fg) 503 | if(len(x['name'])>=13): 504 | newbut.configure(font=smallfont) 505 | 506 | else: 507 | newbut = tk.Button(buttonframe, text=x['name'], command= partial(movefile,x['path']),anchor="w") 508 | itern+=1 509 | elif x['name'] == "SKIP": 510 | newbut = tk.Button(buttonframe, text="SKIP (Space)", command=skip) 511 | tkroot.bind("",skip) 512 | #imagewindow.bind("",skip) 513 | elif x['name'] == "BACK": 514 | newbut = tk.Button(buttonframe, text="BACK", command=back) 515 | newbut.config(font=("Courier",12),width=int((guiframe.winfo_width()/12)/columns),height=1) 516 | if len(x['name'])>20: 517 | newbut.config(font=smallfont) 518 | if guirow > ((tkroot.winfo_height()/35)-2): 519 | guirow=1 520 | guicol+=1 521 | newbut.grid(row=guirow,column=guicol,sticky="ew") 522 | buttons.append(newbut) 523 | guirow+=1 524 | 525 | #textout.grid(column=0,row=0, sticky="nsew") 526 | sdpEntry.config(state=tk.DISABLED) 527 | ddpEntry.config(state=tk.DISABLED) 528 | # zoom 529 | 530 | 531 | 532 | def displayimage(): 533 | global imgiterator 534 | global panel 535 | global guicol 536 | global imageframe 537 | global guiframe 538 | global tkroot 539 | global rescalemode 540 | if imgiterator >= len(imagelist): 541 | if messagebox.askokcancel("Images Sorted!","Reached the end of files, thanks for using Simple Image Sorter. Press OK to quit, or cancel to navigate back if you wish. If you had not reached the end of the files, this is a bug, please report it. Thank you!"): 542 | tkroot.destroy() 543 | saveonexit() 544 | exit(0) 545 | else: 546 | imgiterator = len(imagelist)-1 547 | print("Displaying:"+ imagelist[imgiterator]['path']) 548 | tkroot.winfo_toplevel().title("Simple Image Sorter: " +imagelist[imgiterator]['path']) 549 | imageframe = CanvasImage(toppane,imagelist[imgiterator]['path']) 550 | # takes the smaller scale (since that will be the limiting factor) and rescales the image to that so it fits the frame. 551 | if rescalemode.get(): 552 | print(rescalemode.get()) 553 | imageframe.rescale(min((toppane.winfo_width()-guiframe.winfo_width())/imageframe.imwidth,tkroot.winfo_height()/imageframe.imheight)) 554 | imageframe.grid(column=1,row=0,sticky="NSEW",rowspan=200) 555 | 556 | def folderselect(_type): 557 | folder = tkFileDialog.askdirectory() 558 | if _type == "src": 559 | sdpEntry.delete(0,len(sdpEntry.get())) 560 | sdpEntry.insert(0,folder) 561 | if _type == "des": 562 | ddpEntry.delete(0,len(ddpEntry.get())) 563 | ddpEntry.insert(0,folder) 564 | 565 | def saveonexit(): 566 | global sdp 567 | global ddp 568 | if os.path.exists(sdpEntry.get()): 569 | sdp = sdpEntry.get() 570 | if os.path.exists(ddpEntry.get()): 571 | ddp = ddpEntry.get() 572 | save={"srcpath":sdp, "despath":ddp,"exclude":exclude, "hotkeys":hotkeys} 573 | try: 574 | with open("prefs.json", "w+") as savef: 575 | json.dump(save,savef) 576 | except Exception: 577 | pass 578 | atexit.register(saveonexit) 579 | #gui setup 580 | sdpEntry = tk.Entry(guiframe) #scandirpathEntry 581 | ddpEntry= tk.Entry(guiframe)#dest dir path entry 582 | 583 | sdplabel= tk.Button(guiframe,text="Source Folder:", command=partial(folderselect,"src")) 584 | ddplabel= tk.Button(guiframe,text="Destination Folder:", command=partial(folderselect,"des" )) 585 | activebutton=tk.Button(guiframe,text="Ready",command=validate) 586 | 587 | sdplabel.grid(row=0,column=0,sticky="e") 588 | sdpEntry.grid(row=0,column=1,sticky="w") 589 | ddplabel.grid(row=1,column=0,sticky="e") 590 | ddpEntry.grid(row=1,column=1,sticky="w") 591 | activebutton.grid(row=1,column=2,sticky="ew") 592 | 593 | def excludeshow(): 594 | global exclude 595 | excludewindow = tk.Toplevel() 596 | excludewindow.winfo_toplevel().title("Folder names to ignore, one per line. This will ignore sub-folders too.") 597 | excludetext=tkst.ScrolledText(excludewindow) 598 | for x in exclude: 599 | excludetext.insert("1.0",x+"\n") 600 | excludetext.pack() 601 | excludewindow.protocol("WM_DELETE_WINDOW",partial(excludesave,text=excludetext,toplevelwin=excludewindow)) 602 | 603 | excludebutton=tk.Button(guiframe,text="Manage Exclusions",command=excludeshow) 604 | excludebutton.grid(row=0,column=2) 605 | 606 | def excludesave(text,toplevelwin): 607 | global exclude 608 | text= text.get('1.0', tk.END).splitlines() 609 | exclude=[] 610 | for line in text: 611 | exclude.append(line) 612 | print("List of excluded folder names:") 613 | print(exclude) 614 | try: 615 | toplevelwin.destroy() 616 | except: 617 | pass 618 | #INITIATE 619 | try: 620 | with open("prefs.json","r") as prefsfile: 621 | jdata=prefsfile.read() 622 | jprefs=json.loads(jdata) 623 | print(jprefs) 624 | sdpEntry.delete(0,len(sdpEntry.get())) 625 | ddpEntry.delete(0,len(ddpEntry.get())) 626 | sdpEntry.insert(0,jprefs["srcpath"]) 627 | ddpEntry.insert(0,jprefs["despath"]) 628 | if 'hotkeys' in jprefs: 629 | hotkeys = jprefs["hotkeys"] 630 | exclude=jprefs["exclude"] 631 | if hotkeys=="": 632 | hotkeys="123456qwerty7890uiop[asdfghjkl;zxcvbnm,.!@#$%^QWERT&*()_UIOPASDFGHJKLZXCVBNM<>" 633 | except Exception: 634 | pass 635 | #textout.config(state=tk.DISABLED) 636 | #textout.insert(tk.INSERT,"""No images Loaded yet. 637 | #Enter a source directory in the relevant text field. It can has as many sub-folders as you like, the program will scan them all. 638 | #Enter a root folder to sort to for the "Destination field" too. 639 | #Both of these must have valid paths. That is, they are folders that actually exist. 640 | #The destination directory MUST have sub folders, since those are the folders that you will be sorting to. It is reccomended that the folder names are not more than say, 20 characters long. 641 | #You can always rename them later if you desire longer names. 642 | #Thanks for using this program!""") 643 | #textout.config(state=tk.DISABLED) 644 | tkroot.winfo_toplevel().title("Simple Image Sorter v1.8.2") 645 | 646 | def closeprogram(): 647 | saveonexit() 648 | tkroot.destroy() 649 | exit(0) 650 | tkroot.protocol("WM_DELETE_WINDOW",closeprogram) 651 | 652 | def back(): 653 | global imgiterator 654 | if imgiterator > 0: 655 | imgiterator-=1 656 | if imagelist[imgiterator]["dest"] != "": 657 | shmove(imagelist[imgiterator]["dest"],imagelist[imgiterator]["path"]) 658 | displayimage() 659 | else: 660 | print("can't find last file to go back to!") 661 | 662 | 663 | def buttonResizeOnWindowResize(b=""): 664 | if len(buttons)>0: 665 | for x in buttons: 666 | x.configure(wraplength=buttons[0].winfo_width()-1) 667 | tkroot.bind("", buttonResizeOnWindowResize) 668 | buttonResizeOnWindowResize("a") 669 | tkroot.mainloop() 670 | -------------------------------------------------------------------------------- /gui.py: -------------------------------------------------------------------------------- 1 | from cProfile import label 2 | from operator import indexOf 3 | import tkinter.font as tkfont 4 | from turtle import color 5 | from canvasimage import CanvasImage 6 | import tkinter.scrolledtext as tkst 7 | from tkinter.ttk import Panedwindow, Checkbutton 8 | from PIL import Image, ImageTk 9 | from functools import partial 10 | from tktooltip import ToolTip 11 | import tkinter.scrolledtext as tkst 12 | import tkinter as tk 13 | import logging 14 | from tkinter.messagebox import askokcancel 15 | from math import floor,sqrt 16 | import random 17 | import os 18 | from tkinter import filedialog as tkFileDialog 19 | import json 20 | 21 | def import_pyvips(): 22 | "This looks scary, but it just points to where 'import pyvips' can find it's files from" 23 | "To update this module, change vips-dev-8.16 to your new folder name here and in build.bat" 24 | base_path = os.path.dirname(os.path.abspath(__file__)) 25 | vipsbin = os.path.join(base_path, "vips-dev-8.16", "bin") 26 | 27 | if not os.path.exists(vipsbin): 28 | raise FileNotFoundError(f"The directory {vipsbin} does not exist.") 29 | 30 | os.environ['PATH'] = os.pathsep.join((vipsbin, os.environ['PATH'])) 31 | os.add_dll_directory(vipsbin) 32 | import_pyvips() 33 | try: 34 | import pyvips 35 | except Exception as e: 36 | print("Couldn't import pyvips:", e) 37 | 38 | def luminance(hexin): 39 | color = tuple(int(hexin.lstrip('#')[i:i+2], 16) for i in (0, 2, 4)) 40 | r = color[0] 41 | g = color[1] 42 | b = color[2] 43 | hsp = sqrt( 44 | 0.299 * (r**2) + 45 | 0.587 * (g**2) + 46 | 0.114 * (b**2) 47 | ) 48 | if hsp > 115.6: 49 | return 'light' 50 | else: 51 | return 'dark' 52 | 53 | 54 | def disable_event(): 55 | pass 56 | 57 | def randomColor(): 58 | color = '#' 59 | hexletters = '0123456789ABCDEF' 60 | for i in range(0, 6): 61 | color += hexletters[floor(random.random()*16)] 62 | return color 63 | 64 | def saveprefs(manager, gui): 65 | if os.path.exists(gui.sdpEntry.get()): 66 | sdp = gui.sdpEntry.get() 67 | else: 68 | sdp = "" 69 | if os.path.exists(gui.ddpEntry.get()): 70 | ddp = gui.ddpEntry.get() 71 | else: 72 | ddp = "" 73 | save = {"srcpath": sdp, "despath": ddp, "exclude": manager.exclude, "hotkeys": gui.hotkeys, "thumbnailsize": gui.thumbnailsize, "threads": manager.threads, "hideonassign": gui.hideonassignvar.get( 74 | ), "hidemoved": gui.hidemovedvar.get(), "sortbydate": gui.sortbydatevar.get(), "squaresperpage": gui.squaresperpage.get(), "geometry": gui.winfo_geometry(), "lastsession": gui.sessionpathvar.get(),"autosave":manager.autosave} 75 | try: 76 | with open("prefs.json", "w+") as savef: 77 | json.dump(save, savef,indent=4, sort_keys=True) 78 | logging.debug(save) 79 | except Exception as e: 80 | logging.warning(("Failed to save prefs:", e)) 81 | try: 82 | if manager.autosave: 83 | manager.savesession(False) 84 | except Exception as e: 85 | logging.warning(("Failed to save session:", e)) 86 | 87 | 88 | class GUIManager(tk.Tk): 89 | thumbnailsize = 256 90 | def __init__(self, fileManager) -> None: 91 | super().__init__() 92 | # variable initiation 93 | self.gridsquarelist = [] 94 | self.hideonassignvar = tk.BooleanVar() 95 | self.hideonassignvar.set(True) 96 | self.hidemovedvar = tk.BooleanVar() 97 | self.showhiddenvar = tk.BooleanVar() 98 | self.sortbydatevar = tk.BooleanVar() 99 | self.squaresperpage = tk.IntVar() 100 | self.squaresperpage.set(120) 101 | self.sessionpathvar = tk.StringVar() 102 | self.imagewindowgeometry = str(int(self.winfo_screenwidth( 103 | )*0.80)) + "x" + str(self.winfo_screenheight()-120)+"+365+60" 104 | # store the reference to the file manager class. 105 | self.fileManager = fileManager 106 | self.geometry(str(self.winfo_screenwidth()-5)+"x" + 107 | str(self.winfo_screenheight()-120)) 108 | self.geometry("+0+60") 109 | self.buttons = [] 110 | self.hotkeys = "123456qwerty7890uiop[asdfghjkl;zxcvbnm,.!@#$%^QWERT&*()_UIOPASDFGHJKLZXCVBNM<>" 111 | # Paned window that holds the almost top level stuff. 112 | self.toppane = Panedwindow(self, orient="horizontal") 113 | # Frame for the left hand side that holds the setup and also the destination buttons. 114 | self.leftui = tk.Frame(self.toppane) 115 | #self.leftui.grid(row=0, column=0, sticky="NESW") 116 | self.leftui.columnconfigure(0, weight=1) 117 | self.toppane.add(self.leftui, weight=1) 118 | 119 | #Add a checkbox to check for sorting preference. 120 | self.sortbydatecheck = Checkbutton(self.leftui, text="Sort by Date", variable=self.sortbydatevar, onvalue=True, offvalue=False, command=self.sortbydatevar) 121 | self.sortbydatecheck.grid(row=2, column=0, sticky="w", padx=25) 122 | 123 | self.panel = tk.Label(self.leftui, wraplength=300, justify="left", text="""Select a source directory to search for images in above. 124 | The program will find all png, gif, jpg, bmp, pcx, tiff, Webp, and psds. It can has as many sub-folders as you like, the program will scan them all (except exclusions). 125 | Enter a root folder to sort to for the "Destination field" too. The destination directory MUST have sub folders, since those are the folders that you will be sorting to. 126 | \d (unless you delete prefs.json). Remember that it's one per line, no commas or anything. 127 | You can change the hotkeys in prefs.json, just type a string of letters and numbers and it'll use that. It differentiates between lower and upper case (anything that uses shift), but not numpad. 128 | 129 | By default the program will only load a portion of the images in the folder for performance reasons. Press the "Add Files" button to make it load another chunk. You can configure how many it adds and loads at once in the program. 130 | 131 | Right-click on Destination Buttons to show which images are assigned to them. (Does not show those that have already been moved) 132 | Right-click on Thumbnails to show a zoomable full view. You can also **rename** images from this view. 133 | 134 | Thanks to FooBar167 on stackoverflow for the advanced (and memory efficient!) Zoom and Pan tkinter class. 135 | Thank you for using this program!""") 136 | self.panel.grid(row=3, column=0, columnspan=200, 137 | rowspan=200, sticky="NSEW") 138 | 139 | self.columnconfigure(0, weight=1) 140 | self.buttonframe = tk.Frame(master=self.leftui) 141 | self.buttonframe.grid( 142 | column=0, row=1, sticky="NSEW") 143 | self.buttonframe.columnconfigure(0, weight=1) 144 | self.entryframe = tk.Frame(master=self.leftui) 145 | self.entryframe.columnconfigure(1, weight=1) 146 | self.sdpEntry = tk.Entry( 147 | self.entryframe, takefocus=False) # scandirpathEntry 148 | self.ddpEntry = tk.Entry( 149 | self.entryframe, takefocus=False) # dest dir path entry 150 | 151 | sdplabel = tk.Button( 152 | self.entryframe, text="Source Folder:", command=partial(self.filedialogselect, self.sdpEntry, "d")) 153 | ddplabel = tk.Button( 154 | self.entryframe, text="Destination Folder:", command=partial(self.filedialogselect, self.ddpEntry, "d")) 155 | self.activebutton = tk.Button( 156 | self.entryframe, text="New Session", command=partial(fileManager.validate, self)) 157 | ToolTip(self.activebutton,delay=1,msg="Start a new Session with the entered source and destination") 158 | self.loadpathentry = tk.Entry( 159 | self.entryframe, takefocus=False, textvariable=self.sessionpathvar) 160 | self.loadbutton = tk.Button( 161 | self.entryframe, text="Load Session", command=self.fileManager.loadsession) 162 | ToolTip(self.loadbutton,delay=1,msg="Load and start the selected session data.") 163 | loadfolderbutton = tk.Button(self.entryframe, text="Session Data:", command=partial( 164 | self.filedialogselect, self.loadpathentry, "f")) 165 | ToolTip(loadfolderbutton,delay=1,msg="Select a session json file to open.") 166 | loadfolderbutton.grid(row=3, column=0, sticky='e') 167 | self.loadbutton.grid(row=3, column=2, sticky='ew') 168 | self.loadpathentry.grid(row=3, column=1, sticky='ew', padx=2) 169 | sdplabel.grid(row=0, column=0, sticky="e") 170 | self.sdpEntry.grid(row=0, column=1, sticky="ew", padx=2) 171 | ddplabel.grid(row=1, column=0, sticky="e") 172 | self.ddpEntry.grid(row=1, column=1, sticky="ew", padx=2) 173 | self.activebutton.grid(row=1, column=2, sticky="ew") 174 | self.excludebutton = tk.Button( 175 | self.entryframe, text="Manage Exclusions", command=self.excludeshow) 176 | self.excludebutton.grid(row=0, column=2) 177 | # show the entry frame, sticky it to the west so it mostly stays put. 178 | self.entryframe.grid(row=0, column=0, sticky="ew") 179 | # Finish setup for the left hand bar. 180 | # Start the grid setup 181 | imagegridframe = tk.Frame(self.toppane) 182 | imagegridframe.grid(row=0, column=1, sticky="NSEW") 183 | self.imagegrid = tk.Text( 184 | imagegridframe, wrap='word', borderwidth=0, highlightthickness=0, state="disabled", background='#a9a9a9') 185 | vbar = tk.Scrollbar(imagegridframe, orient='vertical', 186 | command=self.imagegrid.yview) 187 | vbar.grid(row=0, column=1, sticky='ns') 188 | self.imagegrid.configure(yscrollcommand=vbar.set) 189 | self.imagegrid.grid(row=0, column=0, sticky="NSEW") 190 | imagegridframe.rowconfigure(0, weight=1) 191 | imagegridframe.columnconfigure(0, weight=1) 192 | 193 | self.toppane.add(imagegridframe, weight=3) 194 | self.toppane.grid(row=0, column=0, sticky="NSEW") 195 | self.toppane.configure() 196 | self.columnconfigure(0, weight=10) 197 | self.columnconfigure(1, weight=0) 198 | self.rowconfigure(0, weight=10) 199 | self.rowconfigure(1, weight=0) 200 | self.protocol("WM_DELETE_WINDOW", self.closeprogram) 201 | self.winfo_toplevel().title("Simple Image Sorter: Multiview Edition v2.4") 202 | self.leftui.bind("", self.buttonResizeOnWindowResize) 203 | self.buttonResizeOnWindowResize("a") 204 | 205 | def showall(self): 206 | for x in self.fileManager.imagelist: 207 | if x.guidata["show"] == False: 208 | x.guidata["frame"].grid() 209 | self.hidemoved() 210 | self.hideassignedsquare(self.fileManager.imagelist) 211 | 212 | def closeprogram(self): 213 | if self.fileManager.hasunmoved: 214 | if askokcancel("Designated but Un-Moved files, really quit?","You have destination designated, but unmoved files. (Simply cancel and Move All if you want)"): 215 | saveprefs(self.fileManager, self) 216 | self.destroy() 217 | exit(0) 218 | else: 219 | saveprefs(self.fileManager, self) 220 | self.destroy() 221 | exit(0) 222 | 223 | 224 | def excludeshow(self): 225 | excludewindow = tk.Toplevel() 226 | excludewindow.winfo_toplevel().title( 227 | "Folder names to ignore, one per line. This will ignore sub-folders too.") 228 | excludetext = tkst.ScrolledText(excludewindow) 229 | for x in self.fileManager.exclude: 230 | excludetext.insert("1.0", x+"\n") 231 | excludetext.pack() 232 | excludewindow.protocol("WM_DELETE_WINDOW", partial( 233 | self.excludesave, text=excludetext, toplevelwin=excludewindow)) 234 | 235 | def excludesave(self, text, toplevelwin): 236 | text = text.get('1.0', tk.END).splitlines() 237 | exclude = [] 238 | for line in text: 239 | if line != "": 240 | exclude.append(line) 241 | self.fileManager.exclude = exclude 242 | try: 243 | toplevelwin.destroy() 244 | except: 245 | pass 246 | 247 | 248 | def tooltiptext(self,imageobject): 249 | text="" 250 | if imageobject.dupename: 251 | text += "Image has Duplicate Filename!\n" 252 | text += "Leftclick to select this for assignment. Rightclick to open full view" 253 | return text 254 | 255 | def makegridsquare(self, parent, imageobj, setguidata): 256 | frame = tk.Frame(parent, width=self.thumbnailsize + 257 | 14, height=self.thumbnailsize+24) 258 | frame.obj = imageobj 259 | truncated_filename = self.truncate_text(imageobj) 260 | truncated_name_var = tk.StringVar(frame, value=truncated_filename) 261 | frame.obj2 = truncated_name_var # This is needed or it is garbage collected I guess 262 | frame.grid_propagate(True) 263 | 264 | try: 265 | if setguidata: 266 | if not os.path.exists(imageobj.thumbnail): 267 | self.fileManager.makethumb(imageobj) 268 | try: 269 | buffer = pyvips.Image.new_from_file(imageobj.thumbnail) 270 | img = ImageTk.PhotoImage(Image.frombuffer( 271 | "RGB", [buffer.width, buffer.height], buffer.write_to_memory())) 272 | except: # Pillow fallback 273 | img = ImageTk.PhotoImage(Image.open(imageobj.thumbnail)) 274 | else: 275 | img = imageobj.guidata['img'] 276 | 277 | canvas = tk.Canvas(frame, width=self.thumbnailsize, 278 | height=self.thumbnailsize) 279 | tooltiptext=tk.StringVar(frame,self.tooltiptext(imageobj)) 280 | ToolTip(canvas,msg=tooltiptext.get,delay=1) 281 | canvas.create_image( 282 | self.thumbnailsize/2, self.thumbnailsize/2, anchor="center", image=img) 283 | check = Checkbutton( 284 | frame, textvariable=truncated_name_var, variable=imageobj.checked, onvalue=True, offvalue=False) 285 | canvas.grid(column=0, row=0, sticky="NSEW") 286 | check.grid(column=0, row=1, sticky="N") 287 | frame.rowconfigure(0, weight=4) 288 | frame.rowconfigure(1, weight=1) 289 | frame.config(height=self.thumbnailsize+12) 290 | if(setguidata): # save the data to the image obj to both store a reference and for later manipulation 291 | imageobj.setguidata( 292 | {"img": img, "frame": frame, "canvas": canvas, "check": check, "show": True,"tooltip":tooltiptext}) 293 | # anything other than rightclicking toggles the checkbox, as we want. 294 | canvas.bind("", partial(bindhandler, check, "invoke")) 295 | canvas.bind( 296 | "", partial(self.displayimage, imageobj)) 297 | check.bind("", partial(self.displayimage, imageobj)) 298 | canvas.bind("", partial( 299 | bindhandler, parent, "scroll")) 300 | frame.bind("", partial( 301 | bindhandler, self.imagegrid, "scroll")) 302 | check.bind("", partial( 303 | bindhandler, self.imagegrid, "scroll")) 304 | if imageobj.moved: 305 | frame.configure( 306 | highlightbackground="green", highlightthickness=2) 307 | if os.path.dirname(imageobj.path) in self.fileManager.destinationsraw: 308 | color = self.fileManager.destinations[indexOf( 309 | self.fileManager.destinationsraw,os.path.dirname(imageobj.path))]['color'] 310 | frame['background'] = color 311 | canvas['background'] = color 312 | frame.configure(height=self.thumbnailsize+10) 313 | if imageobj.dupename: 314 | frame.configure( 315 | highlightbackground="yellow", highlightthickness=2) 316 | except Exception as e: 317 | logging.error(e) 318 | return frame 319 | 320 | def truncate_text(self, imageobj): #max_length must be over 3+extension or negative indexes happen. 321 | filename = imageobj.name.get() 322 | base_name, ext = os.path.splitext(filename) 323 | smallfont = self.smallfont 324 | text_width = smallfont.measure(filename) 325 | 326 | if text_width+24 <= self.thumbnailsize: 327 | 328 | return filename # Return whole filename 329 | 330 | ext = ".." + ext 331 | 332 | while True: # Return filename that has been truncated. 333 | test_text = base_name + ext # Test with one less character 334 | text_width = smallfont.measure(test_text) 335 | if text_width+24 < self.thumbnailsize: # Reserve space for ellipsis 336 | break 337 | base_name = base_name[:-1] 338 | return test_text 339 | 340 | def displaygrid(self, imagelist, range): 341 | for i in range: 342 | gridsquare = self.makegridsquare( 343 | self.imagegrid, imagelist[i], True) 344 | self.gridsquarelist.append(gridsquare) 345 | self.imagegrid.window_create("insert", window=gridsquare) 346 | 347 | def buttonResizeOnWindowResize(self, b=""): 348 | if len(self.buttons) > 0: 349 | for x in self.buttons: 350 | x.configure(wraplength=(self.buttons[0].winfo_width()-1)) 351 | 352 | def displayimage(self, imageobj, a): 353 | path = imageobj.path 354 | if hasattr(self, 'imagewindow'): 355 | self.imagewindow.destroy() 356 | 357 | self.imagewindow = tk.Toplevel() 358 | imagewindow = self.imagewindow 359 | imagewindow.rowconfigure(1, weight=1) 360 | imagewindow.columnconfigure(0, weight=1) 361 | imagewindow.wm_title("Image: " + path) 362 | imagewindow.geometry(self.imagewindowgeometry) 363 | imageframe = CanvasImage(imagewindow, path) 364 | # takes the smaller scale (since that will be the limiting factor) and rescales the image to that so it fits the frame. 365 | imageframe.rescale(min(imagewindow.winfo_width( 366 | )/imageframe.imwidth, imagewindow.winfo_height()/imageframe.imheight)) 367 | imageframe.grid(column=0, row=1) 368 | imagewindow.bind( 369 | "", partial(bindhandler, imagewindow, "destroy")) 370 | renameframe = tk.Frame(imagewindow) 371 | renameframe.columnconfigure(1, weight=1) 372 | namelabel = tk.Label(renameframe, text="Image Name:") 373 | namelabel.grid(column=0, row=0, sticky="W") 374 | nameentry = tk.Entry( 375 | renameframe, textvariable=imageobj.name, takefocus=False) 376 | nameentry.grid(row=0, column=1, sticky="EW") 377 | 378 | renameframe.grid(column=0, row=0, sticky="EW") 379 | imagewindow.protocol("WM_DELETE_WINDOW", self.saveimagewindowgeo) 380 | imagewindow.obj = imageobj 381 | 382 | def saveimagewindowgeo(self): 383 | self.imagewindowgeometry = self.imagewindow.winfo_geometry() 384 | self.checkdupename(self.imagewindow.obj) 385 | self.imagewindow.destroy() 386 | 387 | def filedialogselect(self, target, type): 388 | if type == "d": 389 | path = tkFileDialog.askdirectory() 390 | elif type == "f": 391 | d = tkFileDialog.askopenfile(initialdir=os.getcwd( 392 | ), title="Select Session Data File", filetypes=(("JavaScript Object Notation", "*.json"),)) 393 | path = d.name 394 | if isinstance(target, tk.Entry): 395 | target.delete(0, tk.END) 396 | target.insert(0, path) 397 | 398 | def guisetup(self, destinations): 399 | self.sortbydatecheck.destroy() #Hides the sortbydate checkbox when you search 400 | sdpEntry = self.sdpEntry 401 | ddpEntry = self.ddpEntry 402 | sdpEntry.config(state=tk.DISABLED) 403 | ddpEntry.config(state=tk.DISABLED) 404 | panel = self.panel 405 | buttonframe = self.buttonframe 406 | hotkeys = self.hotkeys 407 | for key in hotkeys: 408 | self.unbind_all(key) 409 | for x in self.buttons: 410 | x.destroy() # clear the gui 411 | panel.destroy() 412 | guirow = 1 413 | guicol = 0 414 | itern = 0 415 | smallfont = tkfont.Font(family='Helvetica', size=10) 416 | self.smallfont = smallfont 417 | columns = 1 418 | if len(destinations) > int((self.leftui.winfo_height()/35)-2): 419 | columns=2 420 | buttonframe.columnconfigure(1, weight=1) 421 | if len(destinations) > int((self.leftui.winfo_height()/15)-4): 422 | columns = 3 423 | buttonframe.columnconfigure(2, weight=1) 424 | for x in destinations: 425 | color = x['color'] 426 | if x['name'] != "SKIP" and x['name'] != "BACK": 427 | if(itern < len(hotkeys)): 428 | newbut = tk.Button(buttonframe, text=hotkeys[itern] + ": " + x['name'], command=partial( 429 | self.fileManager.setDestination, x, {"widget": None}), anchor="w", wraplength=(self.leftui.winfo_width()/columns)-1) 430 | self.bind_all(hotkeys[itern], partial( 431 | self.fileManager.setDestination, x)) 432 | fg = 'white' 433 | if luminance(color) == 'light': 434 | fg = "black" 435 | newbut.configure(bg=color, fg=fg) 436 | if(len(x['name']) >= 13): 437 | newbut.configure(font=smallfont) 438 | else: 439 | newbut = tk.Button(buttonframe, text=x['name'], command=partial( 440 | self.fileManager.setDestination, x, {"widget": None}), anchor="w") 441 | itern += 1 442 | newbut.config(font=("Courier", 12), width=int( 443 | (self.leftui.winfo_width()/12)/columns), height=1) 444 | ToolTip(newbut,msg="Rightclick to show images assigned to this destination",delay=1) 445 | if len(x['name']) > 20: 446 | newbut.config(font=smallfont) 447 | newbut.dest = x 448 | if guirow > ((self.leftui.winfo_height()/35)-2): 449 | guirow = 1 450 | guicol += 1 451 | newbut.grid(row=guirow, column=guicol, sticky="nsew") 452 | newbut.bind("", partial(self.showthisdest, x)) 453 | 454 | self.buttons.append(newbut) 455 | guirow += 1 456 | self.entryframe.grid_remove() 457 | # options frame 458 | optionsframe = tk.Frame(self.leftui) 459 | # have this just get checked when setting dstination then hide or not 460 | hideonassign = tk.Checkbutton(optionsframe, text="Hide Assigned", 461 | variable=self.hideonassignvar, onvalue=True, offvalue=False) 462 | hideonassign.grid(column=0, row=0, sticky='W') 463 | ToolTip(hideonassign,delay=1,msg="When checked, images that are assigned to a destination be hidden from the grid.") 464 | showhidden = tk.Checkbutton(optionsframe, text="Show Hidden Images", 465 | variable=self.showhiddenvar, onvalue=True, offvalue=False, command=self.showhiddensquares) 466 | showhidden.grid(column=0, row=1, sticky="W") 467 | hidemoved = tk.Checkbutton(optionsframe, text="Hide Moved", 468 | variable=self.hidemovedvar, onvalue=True, offvalue=False, command=self.hidemoved) 469 | hidemoved.grid(column=1, row=1, sticky="w") 470 | ToolTip(hidemoved,delay=1,msg="When checked, images that are moved will be hidden from the grid.") 471 | self.showhidden = showhidden 472 | self.hideonassign = hideonassign 473 | 474 | squaresperpageentry = tk.Entry( 475 | optionsframe, textvariable=self.squaresperpage, takefocus=False) 476 | if self.squaresperpage.get() < 0: #this wont let you save -1 477 | self.squaresperpage.set(1) 478 | 479 | ToolTip(squaresperpageentry,delay=1,msg="How many more images to add when Load Images is clicked") 480 | for n in range(0, itern): 481 | squaresperpageentry.unbind(hotkeys[n]) 482 | addpagebut = tk.Button( 483 | optionsframe, text="Load More Images", command=self.addpage) 484 | 485 | ToolTip(addpagebut,msg="Add another batch of files from the source folders.", delay=1) 486 | 487 | squaresperpageentry.grid(row=2, column=0, sticky="E") 488 | addpagebut.grid(row=2, column=1, sticky="EW") 489 | self.addpagebutton = addpagebut 490 | hideonassign.grid(column=1, row=0) 491 | # save button 492 | savebutton = tk.Button(optionsframe,text="Save Session",command=partial(self.fileManager.savesession,True)) 493 | ToolTip(savebutton,delay=1,msg="Save this image sorting session to a file, where it can be loaded at a later time. Assigned destinations and moved images will be saved.") 494 | savebutton.grid(column=0,row=0,sticky="ew") 495 | moveallbutton = tk.Button( 496 | optionsframe, text="Move All", command=self.fileManager.moveall) 497 | moveallbutton.grid(column=1, row=3, sticky="EW") 498 | ToolTip(moveallbutton,delay=1,msg="Move all images to their assigned destinations, if they have one.") 499 | clearallbutton = tk.Button( 500 | optionsframe, text="Clear Selection", command=self.fileManager.clear) 501 | ToolTip(clearallbutton,delay=1,msg="Clear your selection on the grid and any other windows with checkable image grids.") 502 | clearallbutton.grid(row=3, column=0, sticky="EW") 503 | optionsframe.columnconfigure(0, weight=1) 504 | optionsframe.columnconfigure(1, weight=3) 505 | self.optionsframe = optionsframe 506 | self.optionsframe.grid(row=0, column=0, sticky="ew") 507 | self.bind_all("", self.setfocus) 508 | 509 | def setfocus(self, event): 510 | event.widget.focus_set() 511 | 512 | # todo: make 'moved' and 'assigned' lists so the show all etc just has to iterate over those. 513 | 514 | def hideassignedsquare(self, imlist): 515 | if self.hideonassignvar.get(): 516 | for x in imlist: 517 | if x.dest != "": 518 | self.imagegrid.window_configure( 519 | x.guidata["frame"], window='') 520 | x.guidata["show"] = False 521 | 522 | def hideallsquares(self): 523 | for x in self.gridsquarelist: 524 | self.imagegrid.window_configure(x, window="") 525 | 526 | def showhiddensquares(self): 527 | if self.showhiddenvar.get(): 528 | for x in self.gridsquarelist: 529 | try: 530 | x.obj.guidata["frame"] = x 531 | self.imagegrid.window_create("insert", window=x) 532 | except: 533 | pass 534 | 535 | else: 536 | self.hideassignedsquare(self.fileManager.imagelist) 537 | self.hidemoved() 538 | 539 | def showunassigned(self, imlist): 540 | for x in imlist: 541 | if x.guidata["show"] or x.dest == "": 542 | self.imagegrid.window_create( 543 | "insert", window=x.guidata["frame"]) 544 | 545 | def showthisdest(self, dest, *args): 546 | destwindow = tk.Toplevel() 547 | destwindow.geometry(str(int(self.winfo_screenwidth( 548 | )*0.80)) + "x" + str(self.winfo_screenheight()-120)+"+365+60") 549 | destwindow.winfo_toplevel().title( 550 | "Files designated for" + dest['path']) 551 | destgrid = tk.Text(destwindow, wrap='word', borderwidth=0, 552 | highlightthickness=0, state="disabled", background='#a9a9a9') 553 | destgrid.grid(row=0, column=0, sticky="NSEW") 554 | destwindow.columnconfigure(0, weight=1) 555 | destwindow.rowconfigure(0, weight=1) 556 | vbar = tk.Scrollbar(destwindow, orient='vertical', 557 | command=destgrid.yview) 558 | vbar.grid(row=0, column=1, sticky='ns') 559 | for x in self.fileManager.imagelist: 560 | if x.dest == dest['path']: 561 | newframe = self.makegridsquare(destgrid, x, False) 562 | destgrid.window_create("insert", window=newframe) 563 | 564 | def hidemoved(self): 565 | if self.hidemovedvar.get(): 566 | for x in self.fileManager.imagelist: 567 | if x.moved: 568 | try: 569 | self.imagegrid.window_configure( 570 | x.guidata["frame"], window='') 571 | except Exception as e: 572 | #logging.error(e) 573 | pass 574 | 575 | def addpage(self, *args): 576 | filelist = self.fileManager.imagelist 577 | if len(self.gridsquarelist) < len(filelist)-1: 578 | listmax = min(len(self.gridsquarelist) + 579 | self.squaresperpage.get(), len(filelist)-1) 580 | ran = range(len(self.gridsquarelist), listmax) 581 | sublist = filelist[ran[0]:listmax] 582 | self.fileManager.generatethumbnails(sublist) 583 | self.displaygrid(self.fileManager.imagelist, ran) 584 | else: 585 | self.addpagebutton.configure(text="No More Images!",background="#DD3333") 586 | 587 | def checkdupename(self, imageobj): 588 | if imageobj.name.get() in self.fileManager.existingnames: 589 | imageobj.dupename=True 590 | imageobj.guidata["frame"].configure( 591 | highlightbackground="yellow", highlightthickness=2) 592 | else: 593 | imageobj.dupename=False 594 | imageobj.guidata["frame"].configure(highlightthickness=0) 595 | self.fileManager.existingnames.add(imageobj.name.get()) 596 | imageobj.guidata['tooltip'].set(self.tooltiptext(imageobj)) 597 | 598 | def bindhandler(*args): 599 | widget = args[0] 600 | command = args[1] 601 | if command == "invoke": 602 | widget.invoke() 603 | elif command == "destroy": 604 | widget.destroy() 605 | elif command == "scroll": 606 | widget.yview_scroll(-1*floor(args[2].delta/120), "units") 607 | --------------------------------------------------------------------------------