labelimgplusplus 2.0.0a0__py3-none-any.whl

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.
libs/galleryWidget.py ADDED
@@ -0,0 +1,568 @@
1
+ # libs/galleryWidget.py
2
+ """Gallery view widget for image thumbnail display with annotation status."""
3
+
4
+ try:
5
+ from PyQt5.QtGui import QPixmap, QImage, QPainter, QColor, QPen, QImageReader, QIcon, QBrush, QPolygonF
6
+ from PyQt5.QtCore import Qt, QSize, QObject, pyqtSignal, QRunnable, QThreadPool, QTimer, QPointF
7
+ from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
8
+ QListView, QSlider, QLabel)
9
+ except ImportError:
10
+ from PyQt4.QtGui import (QPixmap, QImage, QPainter, QColor, QPen, QImageReader, QIcon, QBrush,
11
+ QWidget, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem,
12
+ QListView, QSlider, QLabel, QPolygonF)
13
+ from PyQt4.QtCore import Qt, QSize, QObject, pyqtSignal, QRunnable, QThreadPool, QPointF
14
+
15
+ import os
16
+ import hashlib
17
+ from collections import OrderedDict
18
+ from enum import IntEnum
19
+ try:
20
+ from xml.etree import ElementTree
21
+ except ImportError:
22
+ ElementTree = None
23
+
24
+
25
+ def generate_color_by_text(text):
26
+ """Generate a consistent color based on text hash."""
27
+ hash_val = int(hashlib.md5(text.encode('utf-8')).hexdigest()[:8], 16)
28
+ r = (hash_val & 0xFF0000) >> 16
29
+ g = (hash_val & 0x00FF00) >> 8
30
+ b = hash_val & 0x0000FF
31
+ # Ensure colors are bright enough
32
+ r = max(100, r)
33
+ g = max(100, g)
34
+ b = max(100, b)
35
+ return QColor(r, g, b)
36
+
37
+
38
+ def parse_yolo_annotations(txt_path, classes_path=None):
39
+ """Parse YOLO format annotations.
40
+
41
+ Returns list of (label, normalized_bbox) where bbox is (x_center, y_center, w, h).
42
+ """
43
+ annotations = []
44
+ if not os.path.isfile(txt_path):
45
+ return annotations
46
+
47
+ # Load class names
48
+ classes = []
49
+ if classes_path and os.path.isfile(classes_path):
50
+ with open(classes_path, 'r') as f:
51
+ classes = [line.strip() for line in f if line.strip()]
52
+
53
+ with open(txt_path, 'r') as f:
54
+ for line in f:
55
+ parts = line.strip().split()
56
+ if len(parts) >= 5:
57
+ class_idx = int(parts[0])
58
+ x_center = float(parts[1])
59
+ y_center = float(parts[2])
60
+ w = float(parts[3])
61
+ h = float(parts[4])
62
+ label = classes[class_idx] if class_idx < len(classes) else f"class_{class_idx}"
63
+ annotations.append((label, (x_center, y_center, w, h)))
64
+ return annotations
65
+
66
+
67
+ def parse_voc_annotations(xml_path):
68
+ """Parse Pascal VOC format annotations.
69
+
70
+ Returns list of (label, normalized_bbox) where bbox is (x_center, y_center, w, h).
71
+ """
72
+ annotations = []
73
+ if not os.path.isfile(xml_path) or ElementTree is None:
74
+ return annotations
75
+
76
+ try:
77
+ tree = ElementTree.parse(xml_path)
78
+ root = tree.getroot()
79
+
80
+ # Get image size for normalization
81
+ size_elem = root.find('size')
82
+ if size_elem is None:
83
+ return annotations
84
+ img_w = int(size_elem.find('width').text)
85
+ img_h = int(size_elem.find('height').text)
86
+
87
+ if img_w <= 0 or img_h <= 0:
88
+ return annotations
89
+
90
+ for obj in root.iter('object'):
91
+ label = obj.find('name').text
92
+ bbox = obj.find('bndbox')
93
+ xmin = float(bbox.find('xmin').text)
94
+ ymin = float(bbox.find('ymin').text)
95
+ xmax = float(bbox.find('xmax').text)
96
+ ymax = float(bbox.find('ymax').text)
97
+
98
+ # Convert to normalized center format
99
+ x_center = (xmin + xmax) / 2 / img_w
100
+ y_center = (ymin + ymax) / 2 / img_h
101
+ w = (xmax - xmin) / img_w
102
+ h = (ymax - ymin) / img_h
103
+ annotations.append((label, (x_center, y_center, w, h)))
104
+ except Exception:
105
+ pass
106
+
107
+ return annotations
108
+
109
+
110
+ def find_annotation_file(image_path, save_dir=None):
111
+ """Find annotation file for an image.
112
+
113
+ Returns (annotation_path, format) or (None, None) if not found.
114
+ Format is 'yolo', 'voc', or 'createml'.
115
+ """
116
+ base = os.path.splitext(os.path.basename(image_path))[0]
117
+ img_dir = os.path.dirname(image_path)
118
+
119
+ # Directories to search
120
+ search_dirs = [img_dir]
121
+ if save_dir and save_dir != img_dir:
122
+ search_dirs.append(save_dir)
123
+
124
+ # Check for YOLO format (.txt)
125
+ for search_dir in search_dirs:
126
+ txt_path = os.path.join(search_dir, base + '.txt')
127
+ if os.path.isfile(txt_path):
128
+ # Find classes.txt
129
+ classes_path = os.path.join(search_dir, 'classes.txt')
130
+ if not os.path.isfile(classes_path):
131
+ classes_path = os.path.join(img_dir, 'classes.txt')
132
+ return txt_path, 'yolo', classes_path if os.path.isfile(classes_path) else None
133
+
134
+ # Check for Pascal VOC format (.xml)
135
+ for search_dir in search_dirs:
136
+ xml_path = os.path.join(search_dir, base + '.xml')
137
+ if os.path.isfile(xml_path):
138
+ return xml_path, 'voc', None
139
+
140
+ return None, None, None
141
+
142
+
143
+ class AnnotationStatus(IntEnum):
144
+ """Enum representing annotation status of an image."""
145
+ NO_LABELS = 0 # Gray border
146
+ HAS_LABELS = 1 # Blue border
147
+ VERIFIED = 2 # Green border
148
+
149
+
150
+ class ThumbnailCache:
151
+ """LRU cache for thumbnail images with O(1) operations using OrderedDict."""
152
+
153
+ def __init__(self, max_size=200):
154
+ self.max_size = max_size
155
+ self._cache = OrderedDict()
156
+
157
+ def get(self, path):
158
+ """Retrieve thumbnail from cache (O(1) with LRU update)."""
159
+ if path in self._cache:
160
+ self._cache.move_to_end(path) # O(1) instead of O(n)
161
+ return self._cache[path]
162
+ return None
163
+
164
+ def put(self, path, pixmap):
165
+ """Store thumbnail in cache with O(1) LRU eviction."""
166
+ if path in self._cache:
167
+ self._cache.move_to_end(path) # O(1)
168
+ self._cache[path] = pixmap
169
+ else:
170
+ if len(self._cache) >= self.max_size:
171
+ self._cache.popitem(last=False) # O(1) eviction
172
+ self._cache[path] = pixmap
173
+
174
+ def clear(self):
175
+ """Clear all cached thumbnails."""
176
+ self._cache.clear()
177
+
178
+ def remove(self, path):
179
+ """Remove specific thumbnail from cache."""
180
+ self._cache.pop(path, None) # O(1)
181
+
182
+
183
+ class ThumbnailLoaderSignals(QObject):
184
+ """Signals for async thumbnail loading."""
185
+ thumbnail_ready = pyqtSignal(str, QImage) # path, image
186
+
187
+
188
+ class ThumbnailLoaderWorker(QRunnable):
189
+ """Worker for async thumbnail generation with annotation overlay."""
190
+
191
+ def __init__(self, image_path, size=100, save_dir=None):
192
+ super().__init__()
193
+ self.image_path = image_path
194
+ self.size = size
195
+ self.save_dir = save_dir
196
+ self.signals = ThumbnailLoaderSignals()
197
+
198
+ def run(self):
199
+ """Load, scale image, and draw annotations in background thread."""
200
+ try:
201
+ reader = QImageReader(self.image_path)
202
+ reader.setAutoTransform(True)
203
+
204
+ original_size = reader.size()
205
+ if original_size.isValid():
206
+ scaled_size = original_size.scaled(
207
+ self.size, self.size,
208
+ Qt.KeepAspectRatio
209
+ )
210
+ reader.setScaledSize(scaled_size)
211
+
212
+ image = reader.read()
213
+ if not image.isNull():
214
+ # Draw annotations on thumbnail
215
+ image = self._draw_annotations(image)
216
+ self.signals.thumbnail_ready.emit(self.image_path, image)
217
+ except Exception:
218
+ pass
219
+
220
+ def _draw_annotations(self, image):
221
+ """Draw bounding boxes on the thumbnail image."""
222
+ # Find annotation file
223
+ ann_path, ann_format, classes_path = find_annotation_file(
224
+ self.image_path, self.save_dir
225
+ )
226
+ if not ann_path:
227
+ return image
228
+
229
+ # Parse annotations
230
+ if ann_format == 'yolo':
231
+ annotations = parse_yolo_annotations(ann_path, classes_path)
232
+ elif ann_format == 'voc':
233
+ annotations = parse_voc_annotations(ann_path)
234
+ else:
235
+ return image
236
+
237
+ if not annotations:
238
+ return image
239
+
240
+ # Draw on image
241
+ img_w = image.width()
242
+ img_h = image.height()
243
+
244
+ painter = QPainter(image)
245
+ painter.setRenderHint(QPainter.Antialiasing)
246
+
247
+ for label, bbox in annotations:
248
+ x_center, y_center, w, h = bbox
249
+
250
+ # Convert normalized coords to pixel coords
251
+ x1 = int((x_center - w / 2) * img_w)
252
+ y1 = int((y_center - h / 2) * img_h)
253
+ x2 = int((x_center + w / 2) * img_w)
254
+ y2 = int((y_center + h / 2) * img_h)
255
+
256
+ # Get color for this label
257
+ color = generate_color_by_text(label)
258
+ pen = QPen(color)
259
+ pen.setWidth(2)
260
+ painter.setPen(pen)
261
+
262
+ # Draw rectangle
263
+ painter.drawRect(x1, y1, x2 - x1, y2 - y1)
264
+
265
+ painter.end()
266
+ return image
267
+
268
+
269
+ class GalleryWidget(QWidget):
270
+ """Gallery widget using QListWidget in IconMode for tiled layout."""
271
+
272
+ image_selected = pyqtSignal(str) # Single click
273
+ image_activated = pyqtSignal(str) # Double click
274
+
275
+ DEFAULT_ICON_SIZE = 100
276
+ MIN_ICON_SIZE = 40
277
+ MAX_ICON_SIZE = 300
278
+
279
+ STATUS_COLORS = {
280
+ AnnotationStatus.NO_LABELS: QColor(150, 150, 150), # Gray
281
+ AnnotationStatus.HAS_LABELS: QColor(66, 133, 244), # Blue
282
+ AnnotationStatus.VERIFIED: QColor(52, 168, 83), # Green
283
+ }
284
+
285
+ def __init__(self, parent=None, show_size_slider=False):
286
+ super().__init__(parent)
287
+
288
+ self._icon_size = self.DEFAULT_ICON_SIZE
289
+ self._show_size_slider = show_size_slider
290
+ self._save_dir = None # Directory where annotations are saved
291
+
292
+ self.thumbnail_cache = ThumbnailCache(max_size=300)
293
+ self.thread_pool = QThreadPool.globalInstance()
294
+ self.thread_pool.setMaxThreadCount(4)
295
+
296
+ self._path_to_item = {}
297
+ self._image_list = []
298
+ self._loading_paths = set()
299
+ self._statuses = {}
300
+ self._loading_thumbnails = False # Guard against re-entrant calls
301
+
302
+ self._setup_ui()
303
+
304
+ def _setup_ui(self):
305
+ """Initialize UI components."""
306
+ self.list_widget = QListWidget(self)
307
+ self.list_widget.setViewMode(QListView.IconMode)
308
+ self._apply_icon_size()
309
+ self.list_widget.setResizeMode(QListView.Adjust)
310
+ self.list_widget.setWrapping(True)
311
+ self.list_widget.setSpacing(5)
312
+ self.list_widget.setMovement(QListView.Static)
313
+ self.list_widget.setUniformItemSizes(True)
314
+ self.list_widget.setWordWrap(True)
315
+
316
+ self.list_widget.itemClicked.connect(self._on_item_clicked)
317
+ self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
318
+ self.list_widget.verticalScrollBar().valueChanged.connect(self._on_scroll)
319
+
320
+ layout = QVBoxLayout(self)
321
+ layout.setContentsMargins(0, 0, 0, 0)
322
+
323
+ # Add size slider if enabled
324
+ if self._show_size_slider:
325
+ slider_layout = QHBoxLayout()
326
+ slider_layout.setContentsMargins(10, 5, 10, 5)
327
+
328
+ self.size_label = QLabel("Size:")
329
+ slider_layout.addWidget(self.size_label)
330
+
331
+ self.size_slider = QSlider(Qt.Horizontal)
332
+ self.size_slider.setMinimum(self.MIN_ICON_SIZE)
333
+ self.size_slider.setMaximum(self.MAX_ICON_SIZE)
334
+ self.size_slider.setValue(self._icon_size)
335
+ self.size_slider.setTickPosition(QSlider.TicksBelow)
336
+ self.size_slider.setTickInterval(20)
337
+ self.size_slider.valueChanged.connect(self._on_size_changed)
338
+ slider_layout.addWidget(self.size_slider)
339
+
340
+ self.size_value_label = QLabel(f"{self._icon_size}px")
341
+ self.size_value_label.setMinimumWidth(45)
342
+ slider_layout.addWidget(self.size_value_label)
343
+
344
+ layout.addLayout(slider_layout)
345
+
346
+ layout.addWidget(self.list_widget)
347
+
348
+ def _apply_icon_size(self):
349
+ """Apply current icon size to list widget."""
350
+ grid_size = self._icon_size + 20
351
+ self.list_widget.setIconSize(QSize(self._icon_size, self._icon_size))
352
+ self.list_widget.setGridSize(QSize(grid_size, grid_size + 20))
353
+
354
+ def _on_size_changed(self, value):
355
+ """Handle size slider change."""
356
+ self._icon_size = value
357
+ if hasattr(self, 'size_value_label'):
358
+ self.size_value_label.setText(f"{value}px")
359
+ self._apply_icon_size()
360
+ # Clear cache and reload thumbnails at new size
361
+ self.thumbnail_cache.clear()
362
+ self._loading_paths.clear()
363
+ self._reload_all_thumbnails()
364
+
365
+ def _reload_all_thumbnails(self):
366
+ """Reload all thumbnails at current size."""
367
+ for path, item in self._path_to_item.items():
368
+ # Set placeholder
369
+ placeholder = QPixmap(self._icon_size, self._icon_size)
370
+ placeholder.fill(QColor(220, 220, 220))
371
+ item.setIcon(QIcon(placeholder))
372
+ item.setSizeHint(QSize(self._icon_size + 20, self._icon_size + 40))
373
+ self._load_visible_thumbnails()
374
+
375
+ def set_image_list(self, image_paths):
376
+ """Populate gallery with images."""
377
+ self.clear()
378
+ self._image_list = list(image_paths)
379
+
380
+ for path in image_paths:
381
+ self._add_item(path)
382
+
383
+ # Defer thumbnail loading to next event loop cycle to prevent blocking
384
+ QTimer.singleShot(0, self._load_visible_thumbnails)
385
+
386
+ def _add_item(self, image_path):
387
+ """Add an item to the list widget."""
388
+ filename = os.path.basename(image_path)
389
+ if len(filename) > 12:
390
+ display_name = filename[:10] + "..."
391
+ else:
392
+ display_name = filename
393
+
394
+ item = QListWidgetItem(display_name)
395
+ item.setToolTip(filename)
396
+ grid_size = self._icon_size + 20
397
+ item.setSizeHint(QSize(grid_size, grid_size + 20))
398
+
399
+ # Set placeholder icon
400
+ placeholder = QPixmap(self._icon_size, self._icon_size)
401
+ placeholder.fill(QColor(220, 220, 220))
402
+ item.setIcon(QIcon(placeholder))
403
+
404
+ # Set initial status color (gray background)
405
+ item.setBackground(QBrush(QColor(240, 240, 240)))
406
+
407
+ # Store path in item's data
408
+ item.setData(Qt.UserRole, image_path)
409
+
410
+ self.list_widget.addItem(item)
411
+ self._path_to_item[image_path] = item
412
+
413
+ def _on_scroll(self):
414
+ """Handle scroll to load visible thumbnails."""
415
+ self._load_visible_thumbnails()
416
+
417
+ def _load_visible_thumbnails(self):
418
+ """Load thumbnails for visible items."""
419
+ # Guard against re-entrant calls during layout/scroll cascades
420
+ if self._loading_thumbnails:
421
+ return
422
+ self._loading_thumbnails = True
423
+ try:
424
+ viewport_rect = self.list_widget.viewport().rect()
425
+ count = self.list_widget.count()
426
+
427
+ for i in range(count):
428
+ item = self.list_widget.item(i)
429
+ item_rect = self.list_widget.visualItemRect(item)
430
+
431
+ # Check if item is visible (with some buffer)
432
+ if item_rect.intersects(viewport_rect.adjusted(0, -200, 0, 200)):
433
+ path = item.data(Qt.UserRole)
434
+ if path and path not in self._loading_paths:
435
+ cached = self.thumbnail_cache.get(path)
436
+ if cached:
437
+ self._set_item_icon(item, cached, path)
438
+ else:
439
+ self._load_thumbnail_async(path)
440
+ finally:
441
+ self._loading_thumbnails = False
442
+
443
+ def _load_thumbnail_async(self, image_path):
444
+ """Load thumbnail in background thread."""
445
+ if image_path in self._loading_paths:
446
+ return
447
+
448
+ self._loading_paths.add(image_path)
449
+ worker = ThumbnailLoaderWorker(image_path, self._icon_size, self._save_dir)
450
+ worker.signals.thumbnail_ready.connect(self._on_thumbnail_loaded)
451
+ self.thread_pool.start(worker)
452
+
453
+ def _on_thumbnail_loaded(self, path, image):
454
+ """Handle loaded thumbnail."""
455
+ self._loading_paths.discard(path)
456
+ pixmap = QPixmap.fromImage(image)
457
+ self.thumbnail_cache.put(path, pixmap)
458
+
459
+ if path in self._path_to_item:
460
+ item = self._path_to_item[path]
461
+ self._set_item_icon(item, pixmap, path)
462
+
463
+ def _set_item_icon(self, item, pixmap, path):
464
+ """Set icon with status border."""
465
+ status = self._statuses.get(path, AnnotationStatus.NO_LABELS)
466
+ bordered_pixmap = self._add_status_border(pixmap, status)
467
+ item.setIcon(QIcon(bordered_pixmap))
468
+
469
+ def _add_status_border(self, pixmap, status):
470
+ """Add colored border to pixmap based on status."""
471
+ border_width = 4
472
+ new_size = self._icon_size + border_width * 2
473
+
474
+ bordered = QPixmap(new_size, new_size)
475
+ bordered.fill(self.STATUS_COLORS[status])
476
+
477
+ painter = QPainter(bordered)
478
+ # Center the original pixmap
479
+ x = border_width + (self._icon_size - pixmap.width()) // 2
480
+ y = border_width + (self._icon_size - pixmap.height()) // 2
481
+ painter.drawPixmap(x, y, pixmap)
482
+ painter.end()
483
+
484
+ return bordered
485
+
486
+ def _on_item_clicked(self, item):
487
+ """Handle item click."""
488
+ path = item.data(Qt.UserRole)
489
+ if path:
490
+ self.image_selected.emit(path)
491
+
492
+ def _on_item_double_clicked(self, item):
493
+ """Handle item double-click."""
494
+ path = item.data(Qt.UserRole)
495
+ if path:
496
+ self.image_activated.emit(path)
497
+
498
+ def select_image(self, image_path):
499
+ """Select the specified image."""
500
+ if image_path in self._path_to_item:
501
+ item = self._path_to_item[image_path]
502
+ self.list_widget.setCurrentItem(item)
503
+ # Block scroll signals to prevent cascade during programmatic scroll
504
+ scrollbar = self.list_widget.verticalScrollBar()
505
+ scrollbar.blockSignals(True)
506
+ self.list_widget.scrollToItem(item)
507
+ scrollbar.blockSignals(False)
508
+ # Load visible thumbnails once after scrolling
509
+ self._load_visible_thumbnails()
510
+
511
+ def update_status(self, image_path, status):
512
+ """Update annotation status for an image."""
513
+ self._statuses[image_path] = status
514
+
515
+ if image_path in self._path_to_item:
516
+ item = self._path_to_item[image_path]
517
+ # Reload icon with new border color
518
+ cached = self.thumbnail_cache.get(image_path)
519
+ if cached:
520
+ self._set_item_icon(item, cached, image_path)
521
+
522
+ def update_all_statuses(self, statuses):
523
+ """Batch update annotation statuses."""
524
+ self._statuses.update(statuses)
525
+ for path, status in statuses.items():
526
+ if path in self._path_to_item:
527
+ item = self._path_to_item[path]
528
+ cached = self.thumbnail_cache.get(path)
529
+ if cached:
530
+ self._set_item_icon(item, cached, path)
531
+
532
+ def clear(self):
533
+ """Clear all items."""
534
+ self.list_widget.clear()
535
+ self._path_to_item.clear()
536
+ self._image_list.clear()
537
+ self._loading_paths.clear()
538
+ self._statuses.clear()
539
+
540
+ def refresh_thumbnail(self, image_path):
541
+ """Force reload of a specific thumbnail."""
542
+ self.thumbnail_cache.remove(image_path)
543
+ self._loading_paths.discard(image_path)
544
+ self._load_thumbnail_async(image_path)
545
+
546
+ def showEvent(self, event):
547
+ """Load visible thumbnails when widget becomes visible."""
548
+ super().showEvent(event)
549
+ # Defer to prevent blocking during rapid show/hide
550
+ QTimer.singleShot(10, self._load_visible_thumbnails)
551
+
552
+ def resizeEvent(self, event):
553
+ """Handle resize."""
554
+ super().resizeEvent(event)
555
+ # Defer to prevent blocking during resize cascade
556
+ QTimer.singleShot(10, self._load_visible_thumbnails)
557
+
558
+ def set_save_dir(self, save_dir):
559
+ """Set the annotation save directory.
560
+
561
+ When changed, clears the cache to reload thumbnails with annotations.
562
+ """
563
+ if self._save_dir != save_dir:
564
+ self._save_dir = save_dir
565
+ # Clear cache so thumbnails reload with annotations
566
+ self.thumbnail_cache.clear()
567
+ self._loading_paths.clear()
568
+ self._reload_all_thumbnails()
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ import sys
4
+ try:
5
+ from PyQt5.QtGui import *
6
+ from PyQt5.QtCore import *
7
+ from PyQt5.QtWidgets import *
8
+ except ImportError:
9
+ # needed for py3+qt4
10
+ # Ref:
11
+ # http://pyqt.sourceforge.net/Docs/PyQt4/incompatible_apis.html
12
+ # http://stackoverflow.com/questions/21217399/pyqt4-qtcore-qvariant-object-instead-of-a-string
13
+ if sys.version_info.major >= 3:
14
+ import sip
15
+ sip.setapi('QVariant', 2)
16
+ from PyQt4.QtGui import *
17
+ from PyQt4.QtCore import *
18
+
19
+ # PyQt5: TypeError: unhashable type: 'QListWidgetItem'
20
+
21
+
22
+ class HashableQListWidgetItem(QListWidgetItem):
23
+
24
+ def __init__(self, *args):
25
+ super(HashableQListWidgetItem, self).__init__(*args)
26
+
27
+ def __hash__(self):
28
+ return hash(id(self))
libs/labelDialog.py ADDED
@@ -0,0 +1,95 @@
1
+ try:
2
+ from PyQt5.QtGui import *
3
+ from PyQt5.QtCore import *
4
+ from PyQt5.QtWidgets import *
5
+ except ImportError:
6
+ from PyQt4.QtGui import *
7
+ from PyQt4.QtCore import *
8
+
9
+ from libs.utils import new_icon, label_validator, trimmed
10
+
11
+ BB = QDialogButtonBox
12
+
13
+
14
+ class LabelDialog(QDialog):
15
+
16
+ def __init__(self, text="Enter object label", parent=None, list_item=None):
17
+ super(LabelDialog, self).__init__(parent)
18
+
19
+ self.edit = QLineEdit()
20
+ self.edit.setText(text)
21
+ self.edit.setValidator(label_validator())
22
+ self.edit.editingFinished.connect(self.post_process)
23
+
24
+ model = QStringListModel()
25
+ model.setStringList(list_item)
26
+ completer = QCompleter()
27
+ completer.setModel(model)
28
+ self.edit.setCompleter(completer)
29
+
30
+ self.button_box = bb = BB(BB.Ok | BB.Cancel, Qt.Horizontal, self)
31
+ bb.button(BB.Ok).setIcon(new_icon('done'))
32
+ bb.button(BB.Cancel).setIcon(new_icon('undo'))
33
+ bb.accepted.connect(self.validate)
34
+ bb.rejected.connect(self.reject)
35
+
36
+ layout = QVBoxLayout()
37
+ layout.addWidget(bb, alignment=Qt.AlignmentFlag.AlignLeft)
38
+ layout.addWidget(self.edit)
39
+
40
+ if list_item is not None and len(list_item) > 0:
41
+ self.list_widget = QListWidget(self)
42
+ for item in list_item:
43
+ self.list_widget.addItem(item)
44
+ self.list_widget.itemClicked.connect(self.list_item_click)
45
+ self.list_widget.itemDoubleClicked.connect(self.list_item_double_click)
46
+ layout.addWidget(self.list_widget)
47
+
48
+ self.setLayout(layout)
49
+
50
+ def validate(self):
51
+ if trimmed(self.edit.text()):
52
+ self.accept()
53
+
54
+ def post_process(self):
55
+ self.edit.setText(trimmed(self.edit.text()))
56
+
57
+ def pop_up(self, text='', move=True):
58
+ """
59
+ Shows the dialog, setting the current text to `text`, and blocks the caller until the user has made a choice.
60
+ If the user entered a label, that label is returned, otherwise (i.e. if the user cancelled the action)
61
+ `None` is returned.
62
+ """
63
+ self.edit.setText(text)
64
+ self.edit.setSelection(0, len(text))
65
+ self.edit.setFocus(Qt.PopupFocusReason)
66
+ if move:
67
+ cursor_pos = QCursor.pos()
68
+
69
+ # move OK button below cursor
70
+ btn = self.button_box.buttons()[0]
71
+ self.adjustSize()
72
+ btn.adjustSize()
73
+ offset = btn.mapToGlobal(btn.pos()) - self.pos()
74
+ offset += QPoint(btn.size().width() // 4, btn.size().height() // 2)
75
+ cursor_pos.setX(max(0, cursor_pos.x() - offset.x()))
76
+ cursor_pos.setY(max(0, cursor_pos.y() - offset.y()))
77
+
78
+ parent_bottom_right = self.parentWidget().geometry()
79
+ max_x = parent_bottom_right.x() + parent_bottom_right.width() - self.sizeHint().width()
80
+ max_y = parent_bottom_right.y() + parent_bottom_right.height() - self.sizeHint().height()
81
+ max_global = self.parentWidget().mapToGlobal(QPoint(max_x, max_y))
82
+ if cursor_pos.x() > max_global.x():
83
+ cursor_pos.setX(max_global.x())
84
+ if cursor_pos.y() > max_global.y():
85
+ cursor_pos.setY(max_global.y())
86
+ self.move(cursor_pos)
87
+ return trimmed(self.edit.text()) if self.exec_() else None
88
+
89
+ def list_item_click(self, t_qlist_widget_item):
90
+ text = trimmed(t_qlist_widget_item.text())
91
+ self.edit.setText(text)
92
+
93
+ def list_item_double_click(self, t_qlist_widget_item):
94
+ self.list_item_click(t_qlist_widget_item)
95
+ self.validate()