imdiff 0.2.0__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {imdiff-0.2.0 → imdiff-0.2.3}/PKG-INFO +4 -1
  2. imdiff-0.2.3/imdiff/__main__.py +7 -0
  3. imdiff-0.2.3/imdiff/cli/dir_diff.py +45 -0
  4. imdiff-0.2.3/imdiff/cli/image_diff.py +42 -0
  5. imdiff-0.2.3/imdiff/cli/main.py +55 -0
  6. imdiff-0.2.3/imdiff/gui/__init__.py +2 -0
  7. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/dir_diff_main_window.py +26 -31
  8. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/file_list_frame.py +91 -43
  9. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/image_canvas.py +30 -10
  10. imdiff-0.2.3/imdiff/gui/image_diff_main_window.py +54 -0
  11. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/image_frame.py +81 -32
  12. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/image_scaling.py +8 -10
  13. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/status_bar.py +21 -5
  14. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/transient_menu.py +4 -4
  15. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/gui/zoom_menu.py +44 -9
  16. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/image_comparator.py +9 -7
  17. imdiff-0.2.3/imdiff/list_files.py +57 -0
  18. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/util.py +21 -10
  19. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/version.py +1 -1
  20. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff.egg-info/PKG-INFO +4 -1
  21. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff.egg-info/requires.txt +1 -0
  22. {imdiff-0.2.0 → imdiff-0.2.3}/pyproject.toml +1 -0
  23. imdiff-0.2.0/imdiff/__main__.py +0 -3
  24. imdiff-0.2.0/imdiff/cli/dir_diff.py +0 -7
  25. imdiff-0.2.0/imdiff/cli/image_diff.py +0 -4
  26. imdiff-0.2.0/imdiff/cli/main.py +0 -25
  27. imdiff-0.2.0/imdiff/gui/__init__.py +0 -2
  28. imdiff-0.2.0/imdiff/gui/image_diff_main_window.py +0 -0
  29. imdiff-0.2.0/imdiff/list_files.py +0 -37
  30. {imdiff-0.2.0 → imdiff-0.2.3}/LICENSE +0 -0
  31. {imdiff-0.2.0 → imdiff-0.2.3}/README.md +0 -0
  32. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/__init__.py +0 -0
  33. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff/cli/__init__.py +0 -0
  34. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff.egg-info/SOURCES.txt +0 -0
  35. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff.egg-info/dependency_links.txt +0 -0
  36. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff.egg-info/entry_points.txt +0 -0
  37. {imdiff-0.2.0 → imdiff-0.2.3}/imdiff.egg-info/top_level.txt +0 -0
  38. {imdiff-0.2.0 → imdiff-0.2.3}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: imdiff
3
- Version: 0.2.0
3
+ Version: 0.2.3
4
4
  Summary: Compare image files in different directories
5
5
  Author-email: "John T. Goetz" <theodore.goetz@gmail.com>
6
6
  Project-URL: homepage, https://gitlab.com/johngoetz/imdiff
@@ -21,6 +21,9 @@ Classifier: Topic :: Utilities
21
21
  Requires-Python: >=3.8
22
22
  Description-Content-Type: text/markdown
23
23
  License-File: LICENSE
24
+ Requires-Dist: ttkbootstrap
25
+ Requires-Dist: numpy
26
+ Requires-Dist: pillow
24
27
 
25
28
  # ImDiff: Compare Directories of Images
26
29
 
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+
6
+ if __name__ == '__main__':
7
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ import difflib
2
+
3
+ from ..list_files import is_image, list_files
4
+ from ..image_comparator import ImageComparator
5
+ from ..gui import DirDiffMainWindow
6
+
7
+
8
+ def app(leftdir=None, rightdir=None):
9
+ win = DirDiffMainWindow()
10
+ win.load(leftdir, rightdir)
11
+ win.mainloop()
12
+
13
+
14
+ def print_diff(leftdir=None, rightdir=None):
15
+ ndiffs = 0
16
+ assert leftdir.is_dir() and rightdir.is_dir(), 'Items must both be directories'
17
+
18
+ rmse_threshold = 0.02
19
+
20
+ for subpath, left, right in list_files(leftdir, rightdir):
21
+ if not left.is_file():
22
+ ndiffs += 1
23
+ print(f'Only in {right.parent}: {subpath}')
24
+ elif not right.is_file():
25
+ ndiffs += 1
26
+ print(f'Only in {left.parent}: {subpath}')
27
+ elif is_image(left) and is_image(right):
28
+ icmp = ImageComparator(left, right)
29
+ if icmp.diff_info != 'identical':
30
+ ndiffs += 1
31
+ if isinstance(icmp.diff_info, str):
32
+ print(f'{icmp.diff_info} (as image): {subpath}')
33
+ elif icmp.diff_info > rmse_threshold:
34
+ print(f'image difference NRMSE {icmp.diff_info:.4f}: {subpath}')
35
+ else:
36
+ diff_output = list(difflib.unified_diff(
37
+ left.read_text().splitlines(),
38
+ right.read_text().splitlines(),
39
+ fromfile=str(left),
40
+ tofile=str(right),
41
+ lineterm=''))
42
+ if diff_output:
43
+ ndiffs += 1
44
+ print('\n'.join(diff_output))
45
+ return ndiffs
@@ -0,0 +1,42 @@
1
+ import difflib
2
+
3
+ from ..list_files import is_image
4
+ from ..image_comparator import ImageComparator
5
+ from ..gui import ImageDiffMainWindow
6
+
7
+
8
+ def app(left=None, right=None):
9
+ win = ImageDiffMainWindow()
10
+ win.load(left, right)
11
+ win.mainloop()
12
+
13
+
14
+ def print_diff(left, right):
15
+ ndiffs = 0
16
+ assert left.is_file() and right.is_file(), 'Items must both be files'
17
+
18
+ rmse_threshold = 0.02
19
+ if is_image(left) and is_image(right):
20
+ icmp = ImageComparator(left, right)
21
+ if icmp.diff_info != 'identical':
22
+ ndiffs += 1
23
+ if icmp.diff_info == 'missing':
24
+ print(f'Only in {left.parent}: {left.name}')
25
+ elif icmp.diff_info == 'new':
26
+ print(f'Only in {right.parent}: {right.name}')
27
+ elif isinstance(icmp.diff_info, str):
28
+ print(f'Images {left} and {right} differ ({icmp.diff_info})')
29
+ elif icmp.diff_info > rmse_threshold:
30
+ print(f'Images {left} and {right} differ NRMSE: {icmp.diff_info}')
31
+ else:
32
+ diff_output = list(difflib.unified_diff(
33
+ left.read_text().splitlines(),
34
+ right.read_text().splitlines(),
35
+ fromfile=str(left),
36
+ tofile=str(right),
37
+ lineterm=''))
38
+ if diff_output:
39
+ ndiffs += 1
40
+ print('\n'.join(diff_output))
41
+
42
+ return ndiffs
@@ -0,0 +1,55 @@
1
+ import argparse
2
+ import logging
3
+ import pathlib
4
+
5
+ from . import dir_diff, image_diff
6
+
7
+
8
+ def parse_args():
9
+ parser = argparse.ArgumentParser(
10
+ prog='imdiff',
11
+ description="""Compare images one by one or directory by directory""",
12
+ )
13
+ parser.add_argument(
14
+ '--summary',
15
+ action='store_true',
16
+ help="""Print diff result and exit. Disables the GUI. Exits with non-zero if
17
+ there are differences.""",
18
+ )
19
+ parser.add_argument(
20
+ 'left',
21
+ default='.',
22
+ type=pathlib.Path,
23
+ help="""Image or directory of images to compare.""",
24
+ )
25
+ parser.add_argument(
26
+ 'right',
27
+ default='.',
28
+ type=pathlib.Path,
29
+ help="""Image or directory of images to compare.""",
30
+ )
31
+ args = parser.parse_args()
32
+ return args
33
+
34
+
35
+ def main():
36
+ #logging.basicConfig(level=logging.DEBUG)
37
+ args = parse_args()
38
+ if args.left.is_dir():
39
+ assert args.right.is_dir(), \
40
+ 'Paths must be either both image files or both directories'
41
+ if args.summary:
42
+ ndiffs = dir_diff.print_diff(args.left, args.right)
43
+ return int(ndiffs > 0)
44
+ else:
45
+ dir_diff.app(args.left, args.right)
46
+ elif args.left.is_file():
47
+ assert args.right.is_file(), \
48
+ 'Paths must be either both image files or both directories'
49
+ if args.summary:
50
+ ndiffs = image_diff.print_diff(args.left, args.right)
51
+ return int(ndiffs > 0)
52
+ else:
53
+ image_diff.app(args.left, args.right)
54
+ else:
55
+ raise OSError(f'{args.left} is not a file or directory')
@@ -0,0 +1,2 @@
1
+ from .dir_diff_main_window import DirDiffMainWindow
2
+ from .image_diff_main_window import ImageDiffMainWindow
@@ -4,7 +4,7 @@ import platform
4
4
  import sys
5
5
 
6
6
  import tkinter as tk
7
- from tkinter import ttk, font
7
+ import ttkbootstrap as ttk
8
8
 
9
9
  from .file_list_frame import FileListFrame
10
10
  from .image_frame import ImageFrame
@@ -14,14 +14,16 @@ from .status_bar import StatusBar
14
14
  log = logging.getLogger(__name__)
15
15
 
16
16
 
17
- class DirDiffMainWindow(tk.Tk):
17
+ class DirDiffMainWindow(ttk.Window):
18
18
  def __init__(self, left_label='left', right_label='right'):
19
- super().__init__()
20
- self.title('Image Comparator')
21
- self.define_custom_style()
19
+ super().__init__(title='Image Comparator', themename='sandstone')
22
20
  self.pane_window = tk.PanedWindow(self, orient=tk.HORIZONTAL, sashwidth=8)
23
- self.file_list_frame = FileListFrame(self.pane_window, on_select=self.on_select,
24
- left_label=left_label, right_label=right_label)
21
+ self.file_list_frame = FileListFrame(
22
+ self.pane_window,
23
+ on_select=self.on_select,
24
+ left_label=left_label,
25
+ right_label=right_label,
26
+ )
25
27
  self.image_frame = ImageFrame(self.pane_window, update_item=self.update_item)
26
28
  self.image_frame.add_operate_buttons(
27
29
  ttk.Button(self.image_frame, text='Delete', command=self.delete),
@@ -32,20 +34,6 @@ class DirDiffMainWindow(tk.Tk):
32
34
  self.layout()
33
35
  self.bind_events()
34
36
 
35
- def define_custom_style(self):
36
- self.option_add('*tearOff', False)
37
- self.style = ttk.Style(self)
38
- if platform.system() == 'Linux':
39
- self.style.theme_use('clam')
40
-
41
- if platform.system() == 'Linux':
42
- height = font.Font().metrics('linespace')
43
- self.style.configure("Treeview", rowheight=height)
44
-
45
- self.style.configure('Raised.TButton', relief='raised')
46
- self.style.configure('Sunken.TButton', relief='sunken')
47
- self.style.configure('White.TFrame', background='white')
48
-
49
37
  def layout(self):
50
38
  self.pane_window.add(self.file_list_frame)
51
39
  self.pane_window.add(self.image_frame)
@@ -54,12 +42,12 @@ class DirDiffMainWindow(tk.Tk):
54
42
  self.columnconfigure(0, weight=1)
55
43
  self.rowconfigure(0, weight=1)
56
44
 
57
- self.wait_visibility()
58
-
59
45
  file_list_frame_minsize = self.file_list_frame.minsize()
60
46
  image_frame_minsize = self.image_frame.minsize()
61
47
 
62
- self.pane_window.paneconfigure(self.file_list_frame, minsize=file_list_frame_minsize.width)
48
+ self.pane_window.paneconfigure(
49
+ self.file_list_frame, minsize=file_list_frame_minsize.width
50
+ )
63
51
 
64
52
  w = file_list_frame_minsize.width + image_frame_minsize.width
65
53
  h = max(file_list_frame_minsize.height, image_frame_minsize.height)
@@ -68,10 +56,17 @@ class DirDiffMainWindow(tk.Tk):
68
56
  def bind_events(self):
69
57
  self.bind_all('<Control-q>', self.destroy)
70
58
  self.bind_all('<Control-c>', self.destroy)
71
- self.bind_all("<Down>", self.file_list_frame.on_down)
72
- self.bind_all("<Shift-Down>", lambda evt: self.file_list_frame.on_down(evt, add=True))
73
- self.bind_all("<Up>", self.file_list_frame.on_up)
74
- self.bind_all("<Shift-Up>", lambda evt: self.file_list_frame.on_up(evt, add=True))
59
+ self.bind_all('<Down>', self.file_list_frame.on_down)
60
+ self.bind_all(
61
+ '<Shift-Down>', lambda evt: self.file_list_frame.on_down(evt, add=True)
62
+ )
63
+ self.bind_all('<Up>', self.file_list_frame.on_up)
64
+ self.bind_all(
65
+ '<Shift-Up>', lambda evt: self.file_list_frame.on_up(evt, add=True)
66
+ )
67
+ self.bind_all('<Delete>', self.delete)
68
+ self.bind_all('<Left>', self.copy_right_to_left)
69
+ self.bind_all('<Right>', self.copy_left_to_right)
75
70
 
76
71
  def destroy(self, *evt):
77
72
  self.file_list_frame.signal_shutdown()
@@ -96,11 +91,11 @@ class DirDiffMainWindow(tk.Tk):
96
91
  if leftdir and rightdir:
97
92
  self.file_list_frame.after_idle(self.file_list_frame.load_files)
98
93
 
99
- def delete(self):
94
+ def delete(self, evt=None):
100
95
  for file_path in self.file_list_frame.file_treeview.selection():
101
96
  self.file_list_frame.delete(file_path)
102
97
 
103
- def copy_left_to_right(self):
98
+ def copy_left_to_right(self, evt=None):
104
99
  for file_path in self.file_list_frame.file_treeview.selection():
105
100
  if comparator := self.file_list_frame.files.get(file_path, None):
106
101
  if comparator is self.image_frame.comparator:
@@ -111,7 +106,7 @@ class DirDiffMainWindow(tk.Tk):
111
106
  comparator.copy_left_to_right()
112
107
  self.update_item(file_path, comparator)
113
108
 
114
- def copy_right_to_left(self):
109
+ def copy_right_to_left(self, evt=None):
115
110
  for file_path in self.file_list_frame.file_treeview.selection():
116
111
  if comparator := self.file_list_frame.files.get(file_path, None):
117
112
  if comparator is self.image_frame.comparator:
@@ -6,10 +6,12 @@ import pathlib
6
6
  import threading
7
7
  import time
8
8
  import queue
9
+ import subprocess
9
10
  import sys
10
11
 
11
12
  import tkinter as tk
12
- from tkinter import filedialog, font, ttk
13
+ from tkinter import filedialog
14
+ import ttkbootstrap as ttk
13
15
 
14
16
  from ..list_files import list_image_files
15
17
  from ..util import Size, separate_thread
@@ -64,15 +66,26 @@ class FileListFrame(ttk.Frame):
64
66
  self.rightdir = None
65
67
  self.progress_var = tk.IntVar()
66
68
 
67
- self.button_choose_left_dir = ttk.Button(self, text=f'{self.left_label.capitalize()}...', command=self.askleftdir)
68
- self.button_choose_right_dir = ttk.Button(self, text=f'{self.right_label.capitalize()}...', command=self.askrightdir)
69
+ self.button_choose_left_dir = ttk.Button(
70
+ self, text=f'{self.left_label.capitalize()}...', command=self.askleftdir
71
+ )
72
+ self.button_choose_right_dir = ttk.Button(
73
+ self, text=f'{self.right_label.capitalize()}...', command=self.askrightdir
74
+ )
75
+ self.button_text_diff = ttk.Button(
76
+ self, text=f'Text Diff', command=self.textdiff
77
+ )
69
78
  self.treeview_frame = tk.Frame(self)
70
- self.progress_bar = ttk.Progressbar(self.treeview_frame, orient=tk.HORIZONTAL, variable=self.progress_var)
79
+ self.progress_bar = ttk.Progressbar(
80
+ self.treeview_frame, orient=tk.HORIZONTAL, variable=self.progress_var
81
+ )
71
82
  self.file_treeview = ttk.Treeview(self.treeview_frame, columns=('stat',))
72
83
  self.file_treeview.heading('#0', text='File')
73
84
  self.file_treeview.heading('stat', text='Stat')
74
- w = font.Font().measure(' Stat (▲) ')
75
- self.file_treeview.column('stat', width=w, minwidth=w, stretch=False, anchor='center')
85
+ w = tk.font.Font().measure(' Stat (▲) ')
86
+ self.file_treeview.column(
87
+ 'stat', width=w, minwidth=w, stretch=False, anchor='center'
88
+ )
76
89
 
77
90
  self.file_treeview.tag_configure('identical', foreground='black')
78
91
  self.file_treeview.tag_configure('missing', foreground='orange')
@@ -83,8 +96,12 @@ class FileListFrame(ttk.Frame):
83
96
  self.file_treeview.tag_configure('different', foreground='red')
84
97
  self.file_treeview.tag_configure('similar', foreground='black')
85
98
 
86
- self.xscroll = ttk.Scrollbar(self.treeview_frame, orient=tk.HORIZONTAL, command=self.file_treeview.xview)
87
- self.yscroll = ttk.Scrollbar(self.treeview_frame, orient=tk.VERTICAL, command=self.file_treeview.yview)
99
+ self.xscroll = ttk.Scrollbar(
100
+ self.treeview_frame, orient=tk.HORIZONTAL, command=self.file_treeview.xview
101
+ )
102
+ self.yscroll = ttk.Scrollbar(
103
+ self.treeview_frame, orient=tk.VERTICAL, command=self.file_treeview.yview
104
+ )
88
105
 
89
106
  self.file_treeview['xscrollcommand'] = self.xscroll.set
90
107
  self.file_treeview['yscrollcommand'] = self.yscroll.set
@@ -99,8 +116,9 @@ class FileListFrame(ttk.Frame):
99
116
  self.treeview_frame.columnconfigure(0, weight=1)
100
117
  self.treeview_frame.rowconfigure(0, weight=1)
101
118
 
102
- self.button_choose_left_dir.grid(column=0, row=0, sticky='nw')
103
- self.button_choose_right_dir.grid(column=1, row=0, sticky='nw')
119
+ self.button_choose_left_dir.grid(column=0, row=0, padx=5, pady=5, sticky='nw')
120
+ self.button_choose_right_dir.grid(column=1, row=0, padx=5, pady=5, sticky='nw')
121
+ self.button_text_diff.grid(column=2, row=0, padx=5, pady=5, sticky='ne')
104
122
  self.treeview_frame.grid(column=0, row=1, columnspan=3, sticky='nsew')
105
123
 
106
124
  self.columnconfigure(2, weight=1)
@@ -108,10 +126,9 @@ class FileListFrame(ttk.Frame):
108
126
 
109
127
  def bind_events(self):
110
128
  self.file_treeview.bind('<<TreeviewSelect>>', self.on_select)
111
- self.file_treeview.bind("<Down>", self.on_down)
112
- self.file_treeview.bind("<Shift-Down>", lambda evt: self.on_down(evt, add=True))
113
- self.file_treeview.bind("<Up>", self.on_up)
114
- self.file_treeview.bind("<Shift-Up>", lambda evt: self.on_up(evt, add=True))
129
+ self.file_treeview.unbind_class('Treeview', '<Down>')
130
+ self.file_treeview.unbind_class('Treeview', '<Up>')
131
+ self.file_treeview.unbind_class('Treeview', '<Right>')
115
132
 
116
133
  def on_down(self, evt, add=False):
117
134
  if sel := self.file_treeview.selection():
@@ -153,7 +170,7 @@ class FileListFrame(ttk.Frame):
153
170
  self.yscroll,
154
171
  )
155
172
  w = sum([w.winfo_width() for w in widgets])
156
- return Size((w, 400))
173
+ return Size((w + 600, 400))
157
174
 
158
175
  def askleftdir(self):
159
176
  if topdir := filedialog.askdirectory(initialdir=self.leftdir):
@@ -165,6 +182,9 @@ class FileListFrame(ttk.Frame):
165
182
  self.rightdir = pathlib.Path(topdir)
166
183
  self.load_files()
167
184
 
185
+ def textdiff(self):
186
+ subprocess.Popen(('meld', self.leftdir, self.rightdir))
187
+
168
188
  def signal_shutdown(self):
169
189
  self.file_queue.status = IterableQueue.Status.Inactive
170
190
  self.file_processing_queue.status = IterableQueue.Status.Inactive
@@ -191,7 +211,7 @@ class FileListFrame(ttk.Frame):
191
211
  self.file_entries.append(file_path)
192
212
  self.file_treeview.insert('', 'end', file_path, text=file_path)
193
213
  col_width = max(200, self.file_treeview.column('#0', 'width'))
194
- item_width = int(0.85 * font.Font().measure(file_path))
214
+ item_width = int(0.85 * tk.font.Font().measure(file_path))
195
215
  if item_width > col_width:
196
216
  self.file_treeview.column('#0', width=item_width)
197
217
 
@@ -235,7 +255,9 @@ class FileListFrame(ttk.Frame):
235
255
  @separate_thread
236
256
  def process_files(self):
237
257
  assert self.file_properties_queue.status == IterableQueue.Status.Filling
238
- with concurrent.futures.ThreadPoolExecutor(max_workers=multiprocessing.cpu_count()) as executor:
258
+ with concurrent.futures.ThreadPoolExecutor(
259
+ max_workers=multiprocessing.cpu_count()
260
+ ) as executor:
239
261
  self.executor = executor
240
262
  try:
241
263
  jobs = []
@@ -259,11 +281,14 @@ class FileListFrame(ttk.Frame):
259
281
  self.progress_bar.grid_forget()
260
282
 
261
283
  def set_file_properties(self, file_path, category, diff_info):
262
- self.file_treeview.set(file_path, 'stat', category)
263
- self.file_treeview.item(file_path, tags=(diff_info,))
264
- if self.progress_bar.grid_info():
265
- self.num_files_loaded += 1
266
- self.update_progress_bar(100 * self.num_files_loaded // len(self.files))
284
+ try:
285
+ self.file_treeview.set(file_path, 'stat', category)
286
+ self.file_treeview.item(file_path, tags=(diff_info,))
287
+ if self.progress_bar.grid_info():
288
+ self.num_files_loaded += 1
289
+ self.update_progress_bar(100 * self.num_files_loaded // len(self.files))
290
+ except tk.TclError as e:
291
+ log.debug(f'exception caught while setting file properties: {e}')
267
292
 
268
293
  @separate_thread
269
294
  def update_properties(self):
@@ -284,26 +309,31 @@ class FileListFrame(ttk.Frame):
284
309
  self.process_files()
285
310
  self.update_properties()
286
311
 
312
+ def select_next(self):
313
+ select_next = None
314
+ if sel := self.file_treeview.selection():
315
+ curitem = sel[-1]
316
+ select_next = (
317
+ self.file_treeview.next(curitem)
318
+ or self.file_treeview.prev(curitem)
319
+ )
320
+ if select_next:
321
+ self.file_treeview.selection_add(select_next)
322
+ self.file_treeview.focus(select_next)
323
+ self.file_treeview.see(select_next)
324
+ else:
325
+ self.file_treeview.selection_set()
326
+ self.on_select()
327
+
287
328
  def delete_entry(self, file_path):
288
329
  del self.files[file_path]
289
330
  self.file_entries.remove(file_path)
290
331
  try:
291
- select_next = None
292
332
  if sel := self.file_treeview.selection():
293
333
  curitem = sel[-1]
294
334
  if curitem == file_path:
295
- select_next = (
296
- self.file_treeview.next(curitem)
297
- or self.file_treeview.prev(curitem)
298
- )
335
+ self.select_next()
299
336
  self.file_treeview.delete(file_path)
300
- if select_next:
301
- self.file_treeview.selection_add(select_next)
302
- self.file_treeview.focus(select_next)
303
- self.file_treeview.see(select_next)
304
- else:
305
- self.file_treeview.selection_set()
306
- self.on_select()
307
337
  except tk.TclError:
308
338
  log.debug(f'ignoring attempt to delete nonexistant entry: {file_path}')
309
339
 
@@ -320,11 +350,14 @@ class FileListFrame(ttk.Frame):
320
350
  try:
321
351
  file_path = str(pathlib.Path(file_path).relative_to(self.rightdir))
322
352
  except ValueError:
323
- file_path = str(pathlib.Path(file_path).relative_to(self.leftdir))
353
+ try:
354
+ file_path = str(pathlib.Path(file_path).relative_to(self.leftdir))
355
+ except ValueError:
356
+ assert not file_path.is_absolute()
324
357
 
325
358
  if (
326
- not (self.leftdir/file_path).exists()
327
- and not (self.rightdir/file_path).exists()
359
+ not (self.leftdir / file_path).exists()
360
+ and not (self.rightdir / file_path).exists()
328
361
  ):
329
362
  self.delete(file_path)
330
363
  return
@@ -336,32 +369,47 @@ class FileListFrame(ttk.Frame):
336
369
  tv = self.file_treeview
337
370
  l = [(pathlib.Path(tv.item(k)['text']), k) for k in tv.get_children()]
338
371
  maxparts = max(len(p.parts) for p, _ in l)
372
+
339
373
  def transform_key(key, nparts=nparts):
340
374
  p = key[0].parts
341
375
  if -len(p) <= nparts < len(p):
342
376
  return (str(pathlib.Path(*p[nparts:])), str(pathlib.Path(*p[:nparts])))
343
377
  else:
344
378
  return (str(p),)
379
+
345
380
  l.sort(key=transform_key, reverse=reverse)
346
381
  for index, (val, k) in enumerate(l):
347
382
  tv.move(k, '', index)
383
+ tv.column('#0', anchor=(tk.E if nparts < 0 else tk.W))
348
384
  if reverse:
349
385
  next_args = (-maxparts if nparts == (maxparts - 1) else (nparts + 1), False)
350
386
  else:
351
387
  next_args = (nparts, True)
352
- tv.heading('#0', text=f'File ({"▼" if reverse else "▲"}{nparts or ""})',
353
- command=lambda: self.sort_filepaths(*next_args))
388
+ tv.heading(
389
+ '#0',
390
+ text=f'File ({"▼" if reverse else "▲"}{nparts or ""})',
391
+ command=lambda: self.sort_filepaths(*next_args),
392
+ )
354
393
  tv.heading('stat', text='Stat')
355
394
 
356
395
  def sort_stat(self, reverse):
357
396
  tv = self.file_treeview
358
- colindex = tv["columns"].index('stat')
359
- l = [(tv.item(k)['values'][colindex], k) for k in tv.get_children()]
397
+ colindex = tv['columns'].index('stat')
398
+ try:
399
+ l = [(tv.item(k)['values'][colindex], k) for k in tv.get_children()]
400
+ except IndexError:
401
+ # do not sort if we are in the proccess of updating the diffs
402
+ return
403
+
360
404
  def transform_key(key):
361
405
  return ('F', 'L', 'D', 'S', '+', '~', '-', '=').index(key[0])
406
+
362
407
  l.sort(key=transform_key, reverse=reverse)
363
408
  for index, (val, k) in enumerate(l):
364
409
  tv.move(k, '', index)
365
410
  tv.heading('#0', text='File')
366
- tv.heading('stat', text=f'Stat ({"▼" if reverse else "▲"})',
367
- command=lambda: self.sort_stat(not reverse))
411
+ tv.heading(
412
+ 'stat',
413
+ text=f'Stat ({"▼" if reverse else "▲"})',
414
+ command=lambda: self.sort_stat(not reverse),
415
+ )
@@ -73,11 +73,17 @@ class ImageCanvas(tk.Canvas):
73
73
  else:
74
74
  image_size = Size((self.image.width, self.image.height))
75
75
  canvas_size = Size((self.winfo_width(), self.winfo_height()))
76
- if canvas_size.width > 0 and canvas_size.height > 0:
77
- border_size = Size((2 * self.border, 2 * self.border))
78
- scaled_size = self.scaling.scaled(image_size, canvas_size - border_size)
79
- if self.scaled_image is None or scaled_size != Size((self.scaled_image.width, self.scaled_image.height)):
80
- self.scaling_percent = int(100 * scaled_size.width / image_size.width)
76
+ border_size = Size((2 * self.border, 2 * self.border))
77
+ scaled_size = self.scaling.scaled(
78
+ image_size, canvas_size - border_size
79
+ )
80
+ if scaled_size.width > 0 and scaled_size.height > 0:
81
+ if self.scaled_image is None or scaled_size != Size(
82
+ (self.scaled_image.width, self.scaled_image.height)
83
+ ):
84
+ self.scaling_percent = int(
85
+ 100 * scaled_size.width / image_size.width
86
+ )
81
87
  self.scaled_image = self.image.resize(scaled_size)
82
88
  log.debug(f'[{self.label}] scale to {self.scaling_percent}%')
83
89
  self.scaled_image_updated = True
@@ -88,7 +94,9 @@ class ImageCanvas(tk.Canvas):
88
94
  log.debug(f'[{self.label}] removed scaled image')
89
95
 
90
96
  def update_displayed_image(self):
91
- log.debug(f'[{self.label}] update displayed image (scaled image: {self.scaled_image}')
97
+ log.debug(
98
+ f'[{self.label}] update displayed image (scaled image: {self.scaled_image}'
99
+ )
92
100
  if self.image:
93
101
  if self.scaled_image and self.scaled_image_updated:
94
102
  self.tkimage = ImageTk.PhotoImage(self.scaled_image)
@@ -96,14 +104,18 @@ class ImageCanvas(tk.Canvas):
96
104
  self.itemconfig(self.image_id, image=self.tkimage)
97
105
  log.debug(f'[{self.label}] updated displayed image')
98
106
  else:
99
- self.image_id = self.create_image(self.border, self.border, image=self.tkimage, anchor='nw')
107
+ self.image_id = self.create_image(
108
+ self.border, self.border, image=self.tkimage, anchor='nw'
109
+ )
100
110
  log.debug(f'[{self.label}] displaying new image')
101
111
  else:
102
112
  self.clear()
103
113
  log.debug(f'[{self.label}] removed scaled image')
104
114
 
105
115
  def update_layout(self):
106
- log.debug(f'[{self.label}] update layout (image id: {self.image_id} scaled image: {self.scaled_image})')
116
+ log.debug(
117
+ f'[{self.label}] update layout (image id: {self.image_id} scaled image: {self.scaled_image})'
118
+ )
107
119
  if self.image_id and self.scaled_image:
108
120
  w = self.winfo_width()
109
121
  h = self.winfo_height()
@@ -112,8 +124,16 @@ class ImageCanvas(tk.Canvas):
112
124
  self.is_draggable = (w < iw) or (h < ih)
113
125
  if self.position is None:
114
126
  self.position = Coordinates(self.coords(self.image_id))
115
- self.position.x = min(max(self.position.x, w - iw - self.border), self.border) if (w < iw) else ((w - iw) // 2)
116
- self.position.y = min(max(self.position.y, h - ih - self.border), self.border) if (h < ih) else ((h - ih) // 2)
127
+ self.position.x = (
128
+ min(max(self.position.x, w - iw - self.border), self.border)
129
+ if (w < iw)
130
+ else ((w - iw) // 2)
131
+ )
132
+ self.position.y = (
133
+ min(max(self.position.y, h - ih - self.border), self.border)
134
+ if (h < ih)
135
+ else ((h - ih) // 2)
136
+ )
117
137
  if self.position != Coordinates(self.coords(self.image_id)):
118
138
  log.debug(f'[{self.label}] move to {self.position}')
119
139
  self.moveto(self.image_id, self.position.x, self.position.y)
@@ -0,0 +1,54 @@
1
+ import logging
2
+ import os
3
+ import platform
4
+ import sys
5
+
6
+ import tkinter as tk
7
+ import ttkbootstrap as ttk
8
+
9
+ from .image_frame import ImageFrame
10
+ from .status_bar import StatusBar
11
+
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class ImageDiffMainWindow(tk.Tk):
17
+ def __init__(self, left_label='left', right_label='right'):
18
+ super().__init__()
19
+ self.title('Image Comparator')
20
+ self.image_frame = ImageFrame(self)
21
+ self.image_frame.add_standard_operate_buttons()
22
+ self.status_bar = StatusBar(self)
23
+ self.layout()
24
+ self.bind_events()
25
+
26
+ def layout(self):
27
+ self.image_frame.grid(column=0, row=0, sticky='nsew')
28
+ self.status_bar.grid(column=0, row=1, sticky='sew')
29
+ self.columnconfigure(0, weight=1)
30
+ self.rowconfigure(0, weight=1)
31
+
32
+ self.wait_visibility()
33
+
34
+ image_frame_minsize = self.image_frame.minsize()
35
+ w = image_frame_minsize.width
36
+ h = image_frame_minsize.height
37
+ self.minsize(w, h)
38
+
39
+ def bind_events(self):
40
+ self.bind_all('<Control-q>', self.destroy)
41
+ self.bind_all('<Control-c>', self.destroy)
42
+ self.bind_all('<Escape>', self.destroy)
43
+ self.bind_all('<Delete>', self.image_frame.delete)
44
+ self.bind_all('<Left>', self.image_frame.copy_left_to_right)
45
+ self.bind_all('<Right>', self.image_frame.copy_right_to_left)
46
+
47
+ def destroy(self, *evt):
48
+ super().destroy()
49
+ sys.exit()
50
+
51
+ def load(self, left=None, right=None):
52
+ self.image_frame.load_images(left, right)
53
+ if comp := self.image_frame.comparator:
54
+ self.after_idle(self.status_bar.update_labels, comp)