setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.4__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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (72) hide show
  1. setiastro/images/rotatearbitrary.png +0 -0
  2. setiastro/saspro/_generated/build_info.py +2 -2
  3. setiastro/saspro/backgroundneutral.py +10 -1
  4. setiastro/saspro/blink_comparator_pro.py +474 -251
  5. setiastro/saspro/crop_dialog_pro.py +11 -1
  6. setiastro/saspro/doc_manager.py +1 -1
  7. setiastro/saspro/function_bundle.py +16 -16
  8. setiastro/saspro/gui/main_window.py +93 -64
  9. setiastro/saspro/gui/mixins/dock_mixin.py +31 -18
  10. setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
  11. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  12. setiastro/saspro/gui/mixins/toolbar_mixin.py +33 -10
  13. setiastro/saspro/multiscale_decomp.py +710 -256
  14. setiastro/saspro/remove_stars_preset.py +55 -13
  15. setiastro/saspro/resources.py +30 -11
  16. setiastro/saspro/selective_color.py +79 -20
  17. setiastro/saspro/shortcuts.py +94 -21
  18. setiastro/saspro/stacking_suite.py +296 -107
  19. setiastro/saspro/star_alignment.py +275 -330
  20. setiastro/saspro/status_log_dock.py +1 -1
  21. setiastro/saspro/swap_manager.py +77 -42
  22. setiastro/saspro/translations/all_source_strings.json +1588 -516
  23. setiastro/saspro/translations/ar_translations.py +915 -684
  24. setiastro/saspro/translations/de_translations.py +442 -463
  25. setiastro/saspro/translations/es_translations.py +277 -47
  26. setiastro/saspro/translations/fr_translations.py +279 -47
  27. setiastro/saspro/translations/hi_translations.py +253 -21
  28. setiastro/saspro/translations/integrate_translations.py +3 -2
  29. setiastro/saspro/translations/it_translations.py +1211 -161
  30. setiastro/saspro/translations/ja_translations.py +3340 -3107
  31. setiastro/saspro/translations/pt_translations.py +3315 -3337
  32. setiastro/saspro/translations/ru_translations.py +351 -117
  33. setiastro/saspro/translations/saspro_ar.qm +0 -0
  34. setiastro/saspro/translations/saspro_ar.ts +15902 -138
  35. setiastro/saspro/translations/saspro_de.qm +0 -0
  36. setiastro/saspro/translations/saspro_de.ts +14428 -133
  37. setiastro/saspro/translations/saspro_es.qm +0 -0
  38. setiastro/saspro/translations/saspro_es.ts +11503 -7821
  39. setiastro/saspro/translations/saspro_fr.qm +0 -0
  40. setiastro/saspro/translations/saspro_fr.ts +11168 -7812
  41. setiastro/saspro/translations/saspro_hi.qm +0 -0
  42. setiastro/saspro/translations/saspro_hi.ts +14733 -135
  43. setiastro/saspro/translations/saspro_it.qm +0 -0
  44. setiastro/saspro/translations/saspro_it.ts +14347 -7821
  45. setiastro/saspro/translations/saspro_ja.qm +0 -0
  46. setiastro/saspro/translations/saspro_ja.ts +14860 -137
  47. setiastro/saspro/translations/saspro_pt.qm +0 -0
  48. setiastro/saspro/translations/saspro_pt.ts +14904 -137
  49. setiastro/saspro/translations/saspro_ru.qm +0 -0
  50. setiastro/saspro/translations/saspro_ru.ts +11766 -168
  51. setiastro/saspro/translations/saspro_sw.qm +0 -0
  52. setiastro/saspro/translations/saspro_sw.ts +15115 -135
  53. setiastro/saspro/translations/saspro_uk.qm +0 -0
  54. setiastro/saspro/translations/saspro_uk.ts +11206 -6729
  55. setiastro/saspro/translations/saspro_zh.qm +0 -0
  56. setiastro/saspro/translations/saspro_zh.ts +10581 -7812
  57. setiastro/saspro/translations/sw_translations.py +282 -56
  58. setiastro/saspro/translations/uk_translations.py +264 -35
  59. setiastro/saspro/translations/zh_translations.py +282 -47
  60. setiastro/saspro/view_bundle.py +17 -17
  61. setiastro/saspro/widgets/minigame/game.js +11 -6
  62. setiastro/saspro/widgets/resource_monitor.py +26 -0
  63. setiastro/saspro/widgets/spinboxes.py +18 -0
  64. setiastro/saspro/wimi.py +65 -65
  65. setiastro/saspro/wims.py +33 -33
  66. setiastro/saspro/window_shelf.py +2 -2
  67. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/METADATA +7 -7
  68. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/RECORD +72 -71
  69. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/WHEEL +0 -0
  70. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/entry_points.txt +0 -0
  71. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/LICENSE +0 -0
  72. {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.4.dist-info}/licenses/license.txt +0 -0
@@ -479,27 +479,77 @@ class MetricsWindow(QWidget):
479
479
  """
480
480
  Called when some frames were deleted/moved out of the list.
481
481
  Does NOT recompute metrics. Just trims cached arrays and re-plots.
482
+
483
+ Robust against:
484
+ - removed indices referring to the old list (out of range)
485
+ - metrics_panel arrays being a different length than _all_images
486
+ - stale _order_all / _current_indices containing out-of-bounds indices
482
487
  """
483
488
  if not removed:
484
489
  return
485
- removed = sorted(set(int(i) for i in removed))
486
490
 
487
- # 1) shrink cached arrays in the panel
488
- self.metrics_panel.remove_frames(removed)
491
+ # Unique + int
492
+ removed = sorted({int(i) for i in removed})
493
+
494
+ # ---- 1) Trim metrics panel caches SAFELY ----
495
+ # Prefer panel's current frame count, because it represents the arrays we must slice.
496
+ n_panel = getattr(self.metrics_panel, "n_frames", None)
497
+ if callable(n_panel):
498
+ n_panel = n_panel()
499
+ if not isinstance(n_panel, int) or n_panel <= 0:
500
+ # fallback: infer from metrics_data if present
501
+ md = getattr(self.metrics_panel, "metrics_data", None)
502
+ if md is not None and len(md) and md[0] is not None:
503
+ try:
504
+ n_panel = int(len(md[0]))
505
+ except Exception:
506
+ n_panel = 0
507
+ else:
508
+ n_panel = 0
489
509
 
490
- # 2) update our “master” list and ordering (object identity unchanged)
491
- # (BlinkTab will already have mutated the underlying list for us)
510
+ if n_panel > 0:
511
+ removed_panel = [i for i in removed if 0 <= i < n_panel]
512
+ if removed_panel:
513
+ self.metrics_panel.remove_frames(removed_panel)
514
+ # else: panel has nothing (or isn't initialized) — just continue with ordering cleanup
515
+
516
+ # ---- 2) Update ordering arrays with the SAME removed set (but clamp later) ----
492
517
  self._order_all = self._reindex_list_after_remove(self._order_all, removed)
493
- self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
518
+ if self._current_indices is not None:
519
+ self._current_indices = self._reindex_list_after_remove(self._current_indices, removed)
494
520
 
495
- # 3) rebuild group list (filters may have disappeared)
521
+ # ---- 3) Rebuild groups (filters may have disappeared) ----
496
522
  self._rebuild_groups_from_images()
497
523
 
498
- # 4) replot current group with updated order
524
+ # ---- 4) Plot with VALID indices only ----
525
+ n_imgs = len(self._all_images) if self._all_images is not None else 0
526
+
527
+ def _sanitize_indices(ixs):
528
+ if not ixs:
529
+ return []
530
+ out = []
531
+ seen = set()
532
+ for i in ixs:
533
+ try:
534
+ ii = int(i)
535
+ except Exception:
536
+ continue
537
+ if 0 <= ii < n_imgs and ii not in seen:
538
+ seen.add(ii)
539
+ out.append(ii)
540
+ return out
541
+
499
542
  indices = self._current_indices if self._current_indices is not None else self._order_all
543
+ indices = _sanitize_indices(indices)
544
+
545
+ # If the current group became empty, fall back to "all"
546
+ if not indices and n_imgs:
547
+ indices = list(range(n_imgs))
548
+ self._current_indices = indices # optional: keeps UI consistent
549
+
500
550
  self.metrics_panel.plot(self._all_images, indices=indices)
501
551
 
502
- # 5) recolor & status
552
+ # ---- 5) Recolor & status ----
503
553
  self.metrics_panel.refresh_colors_and_status()
504
554
  self._update_status()
505
555
 
@@ -1111,12 +1161,13 @@ class BlinkTab(QWidget):
1111
1161
 
1112
1162
  if not use_aggr:
1113
1163
  if stored.dtype == np.uint8:
1114
- disp8 = stored
1164
+ return stored
1115
1165
  elif stored.dtype == np.uint16:
1116
- disp8 = (stored >> 8).astype(np.uint8)
1166
+ return (stored >> 8).astype(np.uint8)
1117
1167
  else:
1118
- disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
1119
- return disp8
1168
+ # display-only normalization for float / weird ranges
1169
+ f01 = self._ensure_float01(stored)
1170
+ return (f01 * 255.0).astype(np.uint8)
1120
1171
 
1121
1172
  base01 = self._as_float01(stored)
1122
1173
 
@@ -1210,11 +1261,29 @@ class BlinkTab(QWidget):
1210
1261
  self.loading_label.setText(self.tr("Loaded {0} image{1}.").format(n, 's' if n != 1 else ''))
1211
1262
 
1212
1263
  def _apply_playback_interval(self, *_):
1213
- # read from custom spin if present
1214
- fps = float(self.speed_spin.value) if hasattr(self, "speed_spin") else float(getattr(self, "play_fps", 1.0))
1264
+ # read from custom spin if present (support both .value() and .value attribute)
1265
+ fps = float(getattr(self, "play_fps", 1.0))
1266
+
1267
+ if hasattr(self, "speed_spin") and self.speed_spin is not None:
1268
+ try:
1269
+ v = getattr(self.speed_spin, "value", None)
1270
+ if callable(v):
1271
+ fps = float(v()) # QDoubleSpinBox-style
1272
+ elif v is not None:
1273
+ fps = float(v) # CustomDoubleSpinBox stores numeric attribute
1274
+ else:
1275
+ # last-resort: try Qt API name
1276
+ fps = float(self.speed_spin.value())
1277
+ except Exception:
1278
+ # fall back to existing play_fps
1279
+ pass
1280
+
1215
1281
  fps = max(0.1, min(10.0, fps))
1216
1282
  self.play_fps = fps
1217
- self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1283
+
1284
+ if hasattr(self, "playback_timer") and self.playback_timer is not None:
1285
+ self.playback_timer.setInterval(int(round(1000.0 / fps))) # 0.1 fps -> 10000 ms
1286
+
1218
1287
 
1219
1288
  def _on_current_item_changed_safe(self, current, previous):
1220
1289
  if not current:
@@ -1239,12 +1308,41 @@ class BlinkTab(QWidget):
1239
1308
  if item is not None:
1240
1309
  self.fileTree.scrollToItem(item, QAbstractItemView.ScrollHint.EnsureVisible)
1241
1310
 
1242
- def toggle_aggressive(self):
1243
- self.aggressive_stretch_enabled = self.aggressive_button.isChecked()
1244
- # force a redisplay of the current image
1245
- cur = self.fileTree.currentItem()
1246
- if cur:
1247
- self.on_item_clicked(cur, 0)
1311
+ def _leaf_path(self, item: QTreeWidgetItem) -> str | None:
1312
+ """Return full path for a leaf item, preferring UserRole; fallback to basename match."""
1313
+ if not item or item.childCount() > 0:
1314
+ return None
1315
+
1316
+ p = item.data(0, Qt.ItemDataRole.UserRole)
1317
+ if p and isinstance(p, str):
1318
+ return p
1319
+
1320
+ # fallback: basename match (legacy items)
1321
+ name = item.text(0).lstrip("⚠️ ").strip()
1322
+ if not name:
1323
+ return None
1324
+ return next((x for x in self.image_paths if os.path.basename(x) == name), None)
1325
+
1326
+
1327
+ def _leaf_index(self, item: QTreeWidgetItem) -> int | None:
1328
+ """Return index into image_paths/loaded_images for a leaf item."""
1329
+ p = self._leaf_path(item)
1330
+ if not p:
1331
+ return None
1332
+ try:
1333
+ return self.image_paths.index(p)
1334
+ except ValueError:
1335
+ return None
1336
+
1337
+
1338
+ def _set_leaf_display(self, item: QTreeWidgetItem, *, base_name: str, flagged: bool, full_path: str):
1339
+ """Update a leaf item's text + UserRole consistently."""
1340
+ disp = base_name
1341
+ if flagged:
1342
+ disp = f"⚠️ {disp}"
1343
+ item.setText(0, disp)
1344
+ item.setData(0, Qt.ItemDataRole.UserRole, full_path)
1345
+
1248
1346
 
1249
1347
  def clearFlags(self):
1250
1348
  """Clear all flagged states, update tree icons & metrics."""
@@ -1507,31 +1605,23 @@ class BlinkTab(QWidget):
1507
1605
 
1508
1606
 
1509
1607
  def _after_list_changed(self, removed_indices: List[int] | None = None):
1510
- """Call after you mutate image_paths/loaded_images. Keeps UI + metrics in sync w/o recompute."""
1511
- # 1) rebuild the tree (groups collapse if empty)
1512
1608
  self._rebuild_tree_from_loaded()
1513
1609
  self.imagesChanged.emit(len(self.loaded_images))
1514
1610
 
1515
- # 2) refresh metrics (if open) WITHOUT recomputing SEP
1516
1611
  if self.metrics_window and self.metrics_window.isVisible():
1517
- if removed_indices:
1518
- # drop points and reindex
1519
- self.metrics_window._all_images = self.loaded_images
1520
- self.metrics_window.remove_indices(list(removed_indices))
1521
- else:
1522
- # just order changed or paths changed -> replot current group
1523
- self.metrics_window.update_metrics(
1524
- self.loaded_images,
1525
- order=self._tree_order_indices()
1526
- )
1612
+ # ✅ safest: rebind images + rebuild plot order from tree
1613
+ self.metrics_window.set_images(self.loaded_images, order=self._tree_order_indices())
1614
+ self._sync_metrics_flags()
1527
1615
 
1528
1616
  def get_tree_item_for_index(self, idx):
1529
- target = os.path.basename(self.image_paths[idx])
1617
+ target_path = self.image_paths[idx]
1530
1618
  for item in self.get_all_leaf_items():
1531
- if item.text(0).lstrip("⚠️ ") == target:
1619
+ p = item.data(0, Qt.ItemDataRole.UserRole)
1620
+ if p == target_path:
1532
1621
  return item
1533
1622
  return None
1534
1623
 
1624
+
1535
1625
  def compute_metric(self, metric_idx, entry):
1536
1626
  """Recompute a single metric for one image. Use cached orig_background for metric 2."""
1537
1627
  # metric 2 is the pre-stretch background we already computed
@@ -1851,26 +1941,28 @@ class BlinkTab(QWidget):
1851
1941
 
1852
1942
 
1853
1943
  def _toggle_flag_on_item(self, item: QTreeWidgetItem, *, sync_metrics: bool = True):
1854
- file_name = item.text(0).lstrip("⚠️ ")
1855
- file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
1856
- if file_path is None:
1944
+ idx = self._leaf_index(item)
1945
+ if idx is None:
1857
1946
  return
1858
1947
 
1859
- idx = self.image_paths.index(file_path)
1860
1948
  entry = self.loaded_images[idx]
1861
- entry['flagged'] = not entry['flagged']
1949
+ entry['flagged'] = not bool(entry.get('flagged', False))
1862
1950
 
1863
1951
  RED = Qt.GlobalColor.red
1864
- palette = self.fileTree.palette()
1865
- normal_color = palette.color(QPalette.ColorRole.WindowText)
1952
+ normal_color = self.fileTree.palette().color(QPalette.ColorRole.WindowText)
1953
+
1954
+ base = os.path.basename(self.image_paths[idx])
1866
1955
 
1867
1956
  if entry['flagged']:
1868
- item.setText(0, f"⚠️ {file_name}")
1957
+ item.setText(0, f"⚠️ {base}")
1869
1958
  item.setForeground(0, QBrush(RED))
1870
1959
  else:
1871
- item.setText(0, file_name)
1960
+ item.setText(0, base)
1872
1961
  item.setForeground(0, QBrush(normal_color))
1873
1962
 
1963
+ # Keep UserRole correct (in case this was a legacy leaf)
1964
+ item.setData(0, Qt.ItemDataRole.UserRole, self.image_paths[idx])
1965
+
1874
1966
  if sync_metrics:
1875
1967
  self._sync_metrics_flags()
1876
1968
 
@@ -2159,53 +2251,30 @@ class BlinkTab(QWidget):
2159
2251
 
2160
2252
  def on_item_clicked(self, item, column):
2161
2253
  self.fileTree.setFocus()
2254
+ if not item or item.childCount() > 0:
2255
+ return
2162
2256
 
2163
- name = item.text(0).lstrip("⚠️ ").strip()
2164
- file_path = next((p for p in self.image_paths if os.path.basename(p) == name), None)
2257
+ file_path = self._leaf_path(item)
2165
2258
  if not file_path:
2166
2259
  return
2167
2260
 
2168
2261
  self._capture_view_center_norm()
2169
2262
 
2170
- idx = self.image_paths.index(file_path)
2171
- entry = self.loaded_images[idx]
2172
- stored = entry['image_data'] # already stretched & clipped at load time
2173
-
2174
- # --- Fast path: just display what we cached in RAM ---
2175
- if not self.aggressive_stretch_enabled:
2176
- # Convert to 8-bit only if needed (no additional stretch)
2177
- if stored.dtype == np.uint8:
2178
- disp8 = stored
2179
- elif stored.dtype == np.uint16:
2180
- disp8 = (stored >> 8).astype(np.uint8) # ~ /257, quick & vectorized
2181
- else: # float32 in [0..1]
2182
- disp8 = (np.clip(stored, 0.0, 1.0) * 255.0).astype(np.uint8)
2183
-
2184
- else:
2185
- # Aggressive mode: compute only here (from float01)
2186
- base01 = self._as_float01(stored)
2187
- # Siril-style autostretch
2188
- if base01.ndim == 2:
2189
- st = siril_style_autostretch(base01, sigma=self.current_sigma)
2190
- disp01 = self._as_float01(st) # <-- IMPORTANT: handles 0..255 or 0..1 correctly
2191
- else:
2192
- base01 = self._as_float01(stored)
2193
-
2194
- if base01.ndim == 2:
2195
- disp01 = self._aggressive_display_boost(base01, strength=self.current_sigma)
2196
- else:
2197
- lum = base01.mean(axis=2).astype(np.float32)
2198
- lum_boost = self._aggressive_display_boost(lum, strength=self.current_sigma)
2199
- gain = lum_boost / (lum + 1e-6)
2200
- disp01 = np.clip(base01 * gain[..., None], 0.0, 1.0)
2263
+ try:
2264
+ idx = self.image_paths.index(file_path)
2265
+ except ValueError:
2266
+ return
2201
2267
 
2202
- disp8 = (disp01 * 255.0).astype(np.uint8)
2268
+ entry = self.loaded_images[idx]
2203
2269
 
2270
+ # ✅ single source of truth (handles aggressive + mono + color)
2271
+ disp8 = self._make_display_frame(entry)
2204
2272
 
2205
2273
  qimage = self.convert_to_qimage(disp8)
2206
2274
  self.current_pixmap = QPixmap.fromImage(qimage)
2207
2275
  self.apply_zoom()
2208
2276
 
2277
+
2209
2278
  def _capture_view_center_norm(self):
2210
2279
  """Remember the current viewport center as a fraction of the content size."""
2211
2280
  sa = self.scroll_area
@@ -2370,44 +2439,94 @@ class BlinkTab(QWidget):
2370
2439
  menu.exec(self.fileTree.mapToGlobal(pos))
2371
2440
 
2372
2441
 
2373
- def push_to_docs(self, item):
2374
- # Resolve file + entry
2375
- file_name = item.text(0).lstrip("⚠️ ")
2376
- file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
2442
+ def push_to_docs(self, item: QTreeWidgetItem):
2443
+ """
2444
+ Push the currently selected blink leaf image into DocManager as a new document,
2445
+ preserving all original metadata (original_header, meta, bit_depth, is_mono, etc.)
2446
+ and swapping ONLY the numpy image array.
2447
+ """
2448
+ if not item or item.childCount() > 0:
2449
+ return
2450
+
2451
+ # --- Resolve full path safely (UserRole-first) ---
2452
+ file_path = item.data(0, Qt.ItemDataRole.UserRole)
2453
+ if not file_path or not isinstance(file_path, str):
2454
+ # legacy fallback: try to map by displayed name
2455
+ file_name = item.text(0).lstrip("⚠️ ").strip()
2456
+ file_path = next((p for p in self.image_paths if os.path.basename(p) == file_name), None)
2457
+
2377
2458
  if not file_path:
2378
2459
  return
2379
- idx = self.image_paths.index(file_path)
2460
+
2461
+ try:
2462
+ idx = self.image_paths.index(file_path)
2463
+ except ValueError:
2464
+ return
2465
+
2380
2466
  entry = self.loaded_images[idx]
2381
2467
 
2382
- # Find main window + doc manager
2468
+ # --- Find main window + doc manager ---
2383
2469
  mw = self._main_window()
2384
2470
  dm = self.doc_manager or (getattr(mw, "docman", None) if mw else None)
2385
2471
  if not mw or not dm:
2386
2472
  QMessageBox.warning(self, self.tr("Document Manager"), self.tr("Main window or DocManager not available."))
2387
2473
  return
2388
2474
 
2389
- # Prepare image + metadata for a real document
2390
- np_image_f01 = self._as_float01(entry['image_data']) # ensure float32 [0..1]
2391
- metadata = {
2392
- 'file_path': file_path,
2393
- 'original_header': entry.get('header', {}),
2394
- 'bit_depth': entry.get('bit_depth'),
2395
- 'is_mono': entry.get('is_mono'),
2396
- 'source': 'BlinkComparatorPro',
2475
+ # --- Build the swapped payload (image replaced, metadata preserved) ---
2476
+ # Whatever you're storing as entry['image_data'] (uint16/float/etc), normalize to float01 for display pipeline.
2477
+ # If your DocManager expects native dtype instead, swap _as_float01 for your native image.
2478
+ np_image_f01 = self._as_float01(entry["image_data"]).astype(np.float32, copy=False)
2479
+
2480
+ # Preserve your full load_image return structure as much as possible:
2481
+ # load_image returns: image, original_header, bit_depth, is_mono, meta
2482
+ original_header = entry.get("original_header", entry.get("header", None))
2483
+ bit_depth = entry.get("bit_depth", None)
2484
+ is_mono = entry.get("is_mono", None)
2485
+ meta = entry.get("meta", {})
2486
+
2487
+ # Keep meta dict style your app uses; add source tag without clobbering
2488
+ if isinstance(meta, dict):
2489
+ meta = dict(meta)
2490
+ meta.setdefault("source", "BlinkComparatorPro")
2491
+ meta.setdefault("file_path", file_path)
2492
+
2493
+ # This is the "all the other stuff" you wanted preserved
2494
+ payload = {
2495
+ "file_path": file_path,
2496
+ "original_header": original_header,
2497
+ "bit_depth": bit_depth,
2498
+ "is_mono": is_mono,
2499
+ "meta": meta,
2500
+ "source": "BlinkComparatorPro",
2397
2501
  }
2502
+
2398
2503
  title = os.path.basename(file_path)
2399
2504
 
2400
- # Create the document using whatever API your DocManager has
2505
+ # --- Create document using whatever DocManager API exists ---
2401
2506
  doc = None
2402
2507
  try:
2403
- if hasattr(dm, "open_array"):
2404
- doc = dm.open_array(np_image_f01, metadata=metadata, title=title)
2508
+ # Preferred: if you have a method that mirrors open_file/load_image shape
2509
+ if hasattr(dm, "open_from_load_image"):
2510
+ # (image, original_header, bit_depth, is_mono, meta)
2511
+ doc = dm.open_from_load_image(np_image_f01, original_header, bit_depth, is_mono, meta, title=title)
2512
+
2513
+ elif hasattr(dm, "open_array"):
2514
+ # Some of your code expects metadata in doc.metadata; pass payload whole
2515
+ doc = dm.open_array(np_image_f01, metadata=payload, title=title)
2516
+
2405
2517
  elif hasattr(dm, "open_numpy"):
2406
- doc = dm.open_numpy(np_image_f01, metadata=metadata, title=title)
2518
+ doc = dm.open_numpy(np_image_f01, metadata=payload, title=title)
2519
+
2407
2520
  elif hasattr(dm, "create_document"):
2408
- doc = dm.create_document(image=np_image_f01, metadata=metadata, name=title)
2521
+ # Try both signatures
2522
+ try:
2523
+ doc = dm.create_document(image=np_image_f01, metadata=payload, name=title)
2524
+ except TypeError:
2525
+ doc = dm.create_document(np_image_f01, payload, title)
2526
+
2409
2527
  else:
2410
- raise AttributeError(self.tr("DocManager lacks open_array/open_numpy/create_document"))
2528
+ raise AttributeError("DocManager lacks a known creation method")
2529
+
2411
2530
  except Exception as e:
2412
2531
  QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("Failed to create document:\n{0}").format(e))
2413
2532
  return
@@ -2416,42 +2535,85 @@ class BlinkTab(QWidget):
2416
2535
  QMessageBox.critical(self, self.tr("Doc Manager"), self.tr("DocManager returned no document."))
2417
2536
  return
2418
2537
 
2419
- # SHOW it: ask the main window to spawn an MDI subwindow
2538
+ # --- Hand off to DocManager flow (DocManager should trigger MDI + window creation) ---
2420
2539
  try:
2421
- mw._spawn_subwindow_for(doc)
2540
+ # If your architecture already auto-spawns windows on documentAdded,
2541
+ # you should NOT call mw._spawn_subwindow_for(doc) here.
2542
+ if hasattr(dm, "add_document"):
2543
+ dm.add_document(doc)
2544
+ elif hasattr(dm, "register_document"):
2545
+ dm.register_document(doc)
2546
+ else:
2547
+ # If open_array/open_numpy already registers the doc internally, do nothing.
2548
+ pass
2549
+
2550
+ # If you *must* spawn manually (older path), keep as fallback
2551
+ if hasattr(mw, "_spawn_subwindow_for"):
2552
+ mw._spawn_subwindow_for(doc)
2553
+
2422
2554
  if hasattr(mw, "_log"):
2423
2555
  mw._log(f"Blink → opened '{title}' as new document")
2556
+
2424
2557
  except Exception as e:
2425
2558
  QMessageBox.critical(self, self.tr("UI"), self.tr("Failed to open subwindow:\n{0}").format(e))
2426
2559
 
2427
2560
 
2561
+
2428
2562
  # optional shim to keep any old calls working
2429
2563
  def push_image_to_manager(self, item):
2430
2564
  self.push_to_docs(item)
2431
2565
 
2432
2566
 
2433
2567
 
2434
- def rename_item(self, item):
2435
- """Allow the user to rename the selected image."""
2436
- current_name = item.text(0).lstrip("⚠️ ")
2437
- new_name, ok = QInputDialog.getText(self, self.tr("Rename Image"), self.tr("Enter new name:"), text=current_name)
2568
+ def rename_item(self, item: QTreeWidgetItem):
2569
+ if not item or item.childCount() > 0:
2570
+ return
2438
2571
 
2439
- if ok and new_name:
2440
- file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
2441
- if file_path:
2442
- # Get the new file path with the new name
2443
- new_file_path = os.path.join(os.path.dirname(file_path), new_name)
2572
+ idx = self._leaf_index(item)
2573
+ if idx is None:
2574
+ return
2575
+
2576
+ old_path = self.image_paths[idx]
2577
+ old_base = os.path.basename(old_path)
2578
+
2579
+ new_name, ok = QInputDialog.getText(
2580
+ self,
2581
+ self.tr("Rename Image"),
2582
+ self.tr("Enter new name:"),
2583
+ text=old_base
2584
+ )
2585
+ if not ok:
2586
+ return
2587
+
2588
+ new_name = (new_name or "").strip()
2589
+ if not new_name:
2590
+ return
2591
+
2592
+ new_path = os.path.join(os.path.dirname(old_path), new_name)
2593
+
2594
+ # Avoid overwrite
2595
+ if os.path.exists(new_path):
2596
+ QMessageBox.critical(self, self.tr("Error"), self.tr("A file with that name already exists."))
2597
+ return
2598
+
2599
+ try:
2600
+ os.rename(old_path, new_path)
2601
+ except Exception as e:
2602
+ QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2603
+ return
2604
+
2605
+ # Update internal structures
2606
+ self.image_paths[idx] = new_path
2607
+ self.loaded_images[idx]['file_path'] = new_path
2608
+
2609
+ # Update the leaf item
2610
+ flagged = bool(self.loaded_images[idx].get("flagged", False))
2611
+ self._set_leaf_display(item, base_name=new_name, flagged=flagged, full_path=new_path)
2612
+
2613
+ # Rebuild so natural sort stays correct and groups update
2614
+ self._after_list_changed()
2615
+ self._sync_metrics_flags()
2444
2616
 
2445
- try:
2446
- # Rename the file
2447
- os.rename(file_path, new_file_path)
2448
- print(f"File renamed from {current_name} to {new_name}")
2449
-
2450
- # Update the image paths and tree view
2451
- self.image_paths[self.image_paths.index(file_path)] = new_file_path
2452
- item.setText(0, new_name)
2453
- except Exception as e:
2454
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2455
2617
 
2456
2618
  def rename_flagged_images(self):
2457
2619
  """Prefix all *flagged* images on disk and in the tree."""
@@ -2562,79 +2724,102 @@ class BlinkTab(QWidget):
2562
2724
 
2563
2725
 
2564
2726
  def batch_rename_items(self):
2565
- """Batch rename selected items by adding a prefix or suffix."""
2566
- selected_items = self.fileTree.selectedItems()
2567
-
2727
+ """Batch rename selected leaf items by adding a prefix and/or suffix."""
2728
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2568
2729
  if not selected_items:
2569
- QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for renaming."))
2730
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for renaming."))
2570
2731
  return
2571
2732
 
2572
- # Create a custom dialog for entering the prefix and suffix
2573
2733
  dialog = QDialog(self)
2574
2734
  dialog.setWindowTitle(self.tr("Batch Rename"))
2575
2735
  dialog_layout = QVBoxLayout(dialog)
2576
2736
 
2577
- instruction_label = QLabel(self.tr("Enter a prefix or suffix to rename selected files:"))
2578
- dialog_layout.addWidget(instruction_label)
2737
+ dialog_layout.addWidget(QLabel(self.tr("Enter a prefix or suffix to rename selected files:"), dialog))
2579
2738
 
2580
- # Create fields for prefix and suffix
2581
2739
  form_layout = QHBoxLayout()
2582
-
2583
2740
  prefix_field = QLineEdit(dialog)
2584
2741
  prefix_field.setPlaceholderText(self.tr("Prefix"))
2585
2742
  form_layout.addWidget(prefix_field)
2586
2743
 
2587
- current_filename_label = QLabel("currentfilename", dialog)
2588
- form_layout.addWidget(current_filename_label)
2744
+ mid_label = QLabel(self.tr("filename"), dialog)
2745
+ mid_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
2746
+ form_layout.addWidget(mid_label)
2589
2747
 
2590
2748
  suffix_field = QLineEdit(dialog)
2591
2749
  suffix_field.setPlaceholderText(self.tr("Suffix"))
2592
2750
  form_layout.addWidget(suffix_field)
2593
-
2594
2751
  dialog_layout.addLayout(form_layout)
2595
2752
 
2596
- # Add OK and Cancel buttons
2597
- button_layout = QHBoxLayout()
2753
+ btns = QHBoxLayout()
2598
2754
  ok_button = QPushButton(self.tr("OK"), dialog)
2599
- ok_button.clicked.connect(dialog.accept)
2600
- button_layout.addWidget(ok_button)
2601
-
2602
2755
  cancel_button = QPushButton(self.tr("Cancel"), dialog)
2756
+ ok_button.clicked.connect(dialog.accept)
2603
2757
  cancel_button.clicked.connect(dialog.reject)
2604
- button_layout.addWidget(cancel_button)
2758
+ btns.addWidget(ok_button)
2759
+ btns.addWidget(cancel_button)
2760
+ dialog_layout.addLayout(btns)
2761
+
2762
+ if dialog.exec() != QDialog.DialogCode.Accepted:
2763
+ return
2605
2764
 
2606
- dialog_layout.addLayout(button_layout)
2765
+ prefix = (prefix_field.text() or "").strip()
2766
+ suffix = (suffix_field.text() or "").strip()
2607
2767
 
2608
- # Show the dialog and handle user input
2609
- if dialog.exec() == QDialog.DialogCode.Accepted:
2610
- prefix = prefix_field.text().strip()
2611
- suffix = suffix_field.text().strip()
2768
+ if not prefix and not suffix:
2769
+ QMessageBox.information(self, self.tr("Batch Rename"), self.tr("No prefix or suffix entered. Nothing to do."))
2770
+ return
2612
2771
 
2613
- # Rename each selected file
2614
- for item in selected_items:
2615
- current_name = item.text(0)
2616
- file_path = next((path for path in self.image_paths if os.path.basename(path) == current_name), None)
2772
+ renamed = 0
2773
+ failures = []
2617
2774
 
2618
- if file_path:
2619
- # Construct the new filename
2620
- directory = os.path.dirname(file_path)
2621
- new_name = f"{prefix}{current_name}{suffix}"
2622
- new_file_path = os.path.join(directory, new_name)
2775
+ # Work on indices so we can update lists safely
2776
+ indices = []
2777
+ for it in selected_items:
2778
+ idx = self._leaf_index(it)
2779
+ if idx is not None:
2780
+ indices.append((idx, it))
2623
2781
 
2624
- try:
2625
- # Rename the file
2626
- os.rename(file_path, new_file_path)
2627
- print(f"File renamed from {file_path} to {new_file_path}")
2782
+ for idx, it in indices:
2783
+ old_path = self.image_paths[idx]
2784
+ directory, base = os.path.split(old_path)
2628
2785
 
2629
- # Update the paths and tree view
2630
- self.image_paths[self.image_paths.index(file_path)] = new_file_path
2631
- item.setText(0, new_name)
2786
+ new_base = f"{prefix}{base}{suffix}"
2787
+ new_path = os.path.join(directory, new_base)
2632
2788
 
2633
- except Exception as e:
2634
- print(f"Failed to rename {file_path}: {e}")
2635
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to rename the file: {0}").format(e))
2789
+ if new_path == old_path:
2790
+ continue
2791
+
2792
+ if os.path.exists(new_path):
2793
+ failures.append((old_path, self.tr("target already exists")))
2794
+ continue
2795
+
2796
+ try:
2797
+ os.rename(old_path, new_path)
2798
+ except Exception as e:
2799
+ failures.append((old_path, str(e)))
2800
+ continue
2801
+
2802
+ # Update internal lists
2803
+ self.image_paths[idx] = new_path
2804
+ self.loaded_images[idx]["file_path"] = new_path
2805
+
2806
+ # Update leaf item
2807
+ flagged = bool(self.loaded_images[idx].get("flagged", False))
2808
+ self._set_leaf_display(it, base_name=new_base, flagged=flagged, full_path=new_path)
2809
+
2810
+ renamed += 1
2811
+
2812
+ # Rebuild so group headers + natural order stay correct
2813
+ self._after_list_changed()
2814
+ self._sync_metrics_flags()
2815
+
2816
+ msg = self.tr("Batch renamed {0} file{1}.").format(renamed, "s" if renamed != 1 else "")
2817
+ if failures:
2818
+ msg += self.tr("\n\n{0} file(s) failed:").format(len(failures))
2819
+ for old, err in failures[:10]:
2820
+ msg += f"\n• {os.path.basename(old)} – {err}"
2821
+ QMessageBox.information(self, self.tr("Batch Rename"), msg)
2636
2822
 
2637
- print(f"Batch renamed {len(selected_items)} items.")
2638
2823
 
2639
2824
  def batch_delete_flagged_images(self):
2640
2825
  """Delete all flagged images."""
@@ -2681,141 +2866,181 @@ class BlinkTab(QWidget):
2681
2866
  self._after_list_changed(removed_indices)
2682
2867
 
2683
2868
  def batch_move_flagged_images(self):
2684
- """Move all flagged images to a selected directory."""
2685
- flagged_images = [img for img in self.loaded_images if img['flagged']]
2686
-
2687
- if not flagged_images:
2869
+ """Move all flagged images to a selected directory AND remove them from the blink list."""
2870
+ flagged_indices = [i for i, e in enumerate(self.loaded_images) if e.get("flagged", False)]
2871
+ if not flagged_indices:
2688
2872
  QMessageBox.information(self, self.tr("No Flagged Images"), self.tr("There are no flagged images to move."))
2689
2873
  return
2690
2874
 
2691
- # Select destination directory
2692
2875
  destination_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
2693
2876
  if not destination_dir:
2694
- return # User canceled
2877
+ return
2695
2878
 
2696
- for img in flagged_images:
2697
- src_path = img['file_path']
2698
- file_name = os.path.basename(src_path)
2699
- dest_path = os.path.join(destination_dir, file_name)
2879
+ failures = []
2700
2880
 
2881
+ # Move first (use current paths from indices)
2882
+ for i in flagged_indices:
2883
+ src_path = self.image_paths[i]
2884
+ dest_path = os.path.join(destination_dir, os.path.basename(src_path))
2701
2885
  try:
2702
2886
  os.rename(src_path, dest_path)
2703
- print(f"Moved flagged image from {src_path} to {dest_path}")
2704
2887
  except Exception as e:
2705
- print(f"Failed to move {src_path}: {e}")
2706
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(src_path, e))
2707
- continue
2888
+ failures.append((src_path, str(e)))
2708
2889
 
2709
- # Update data structures
2710
- self.image_paths.remove(src_path)
2711
- self.image_paths.append(dest_path)
2712
- img['file_path'] = dest_path
2713
- img['flagged'] = False # Reset flag if desired
2890
+ # Remove from lists ONLY if move succeeded
2891
+ # Build a set of indices to remove: those that did NOT fail
2892
+ failed_src = {p for p, _ in failures}
2893
+ removed_indices = [i for i in flagged_indices if self.image_paths[i] not in failed_src]
2714
2894
 
2715
- # Update tree view
2716
- self.remove_item_from_tree(src_path)
2717
- self.add_item_to_tree(dest_path)
2895
+ removed_indices = sorted(set(removed_indices), reverse=True)
2896
+ for idx in removed_indices:
2897
+ if 0 <= idx < len(self.image_paths):
2898
+ del self.image_paths[idx]
2899
+ if 0 <= idx < len(self.loaded_images):
2900
+ del self.loaded_images[idx]
2901
+
2902
+ if removed_indices:
2903
+ self._after_list_changed(removed_indices)
2904
+
2905
+ if failures:
2906
+ msg = self.tr("Moved {0} flagged file(s). {1} failed:").format(len(removed_indices), len(failures))
2907
+ for p, err in failures[:10]:
2908
+ msg += f"\n• {os.path.basename(p)} – {err}"
2909
+ QMessageBox.warning(self, self.tr("Batch Move"), msg)
2910
+ else:
2911
+ QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved and removed {0} flagged image(s).").format(len(removed_indices)))
2718
2912
 
2719
- QMessageBox.information(self, self.tr("Batch Move"), self.tr("Moved {0} flagged images.").format(len(flagged_images)))
2720
- self._after_list_changed(removed_indices=None)
2721
2913
 
2722
2914
  def move_items(self):
2723
- """Move selected images *and* remove them from the tree+metrics."""
2724
- selected_items = self.fileTree.selectedItems()
2915
+ """Move selected leaf images to a selected directory AND remove them from the blink list."""
2916
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2725
2917
  if not selected_items:
2726
- QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for moving."))
2918
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for moving."))
2727
2919
  return
2728
2920
 
2729
- # Ask where to move
2730
- new_dir = QFileDialog.getExistingDirectory(self,
2731
- self.tr("Select Destination Folder"),
2732
- "")
2921
+ new_dir = QFileDialog.getExistingDirectory(self, self.tr("Select Destination Folder"), "")
2733
2922
  if not new_dir:
2734
2923
  return
2735
2924
 
2736
- # Keep track of which on‐disk paths we actually moved
2737
- moved_old_paths = []
2738
2925
  removed_indices = []
2926
+ failures = []
2739
2927
 
2740
- for item in selected_items:
2741
- name = item.text(0).lstrip("⚠️ ")
2742
- old_path = next((p for p in self.image_paths
2743
- if os.path.basename(p) == name), None)
2744
- if not old_path:
2928
+ # Collect (idx, old_path, item) first to avoid index drift
2929
+ triplets = []
2930
+ for it in selected_items:
2931
+ p = self._leaf_path(it)
2932
+ if not p:
2745
2933
  continue
2746
- removed_indices.append(self.image_paths.index(old_path))
2934
+ try:
2935
+ idx = self.image_paths.index(p)
2936
+ except ValueError:
2937
+ continue
2938
+ triplets.append((idx, p, it))
2747
2939
 
2748
- new_path = os.path.join(new_dir, name)
2940
+ for idx, old_path, it in triplets:
2941
+ base = os.path.basename(old_path)
2942
+ new_path = os.path.join(new_dir, base)
2749
2943
  try:
2750
2944
  os.rename(old_path, new_path)
2751
2945
  except Exception as e:
2752
- QMessageBox.critical(self, self.tr("Error"), self.tr("Failed to move {0}: {1}").format(old_path, e))
2946
+ failures.append((old_path, str(e)))
2753
2947
  continue
2754
2948
 
2755
- moved_old_paths.append(old_path)
2949
+ removed_indices.append(idx)
2756
2950
 
2757
- # 1) Remove the leaf from the tree
2758
- parent = item.parent() or self.fileTree.invisibleRootItem()
2759
- parent.removeChild(item)
2951
+ # remove leaf from tree immediately (optional; _after_list_changed will rebuild anyway)
2952
+ #parent = it.parent() or self.fileTree.invisibleRootItem()
2953
+ #parent.removeChild(it)
2760
2954
 
2761
- # 2) Purge them from your internal lists
2762
- for idx in sorted(removed_indices, reverse=True):
2763
- del self.image_paths[idx]
2764
- del self.loaded_images[idx]
2765
-
2766
- self._after_list_changed(removed_indices)
2767
- print(f"Moved and removed {len(removed_indices)} items.")
2955
+ # Purge arrays descending
2956
+ removed_indices = sorted(set(removed_indices), reverse=True)
2957
+ for idx in removed_indices:
2958
+ if 0 <= idx < len(self.image_paths):
2959
+ del self.image_paths[idx]
2960
+ if 0 <= idx < len(self.loaded_images):
2961
+ del self.loaded_images[idx]
2768
2962
 
2963
+ if removed_indices:
2964
+ self._after_list_changed(removed_indices)
2769
2965
 
2966
+ if failures:
2967
+ msg = self.tr("Moved {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
2968
+ for old, err in failures[:10]:
2969
+ msg += f"\n• {os.path.basename(old)} – {err}"
2970
+ QMessageBox.warning(self, self.tr("Move Selected Items"), msg)
2971
+ else:
2972
+ QMessageBox.information(self, self.tr("Move Selected Items"), self.tr("Moved and removed {0} item(s).").format(len(removed_indices)))
2770
2973
 
2771
2974
  def delete_items(self):
2772
- """Delete the selected items from the tree, the loaded images list, and the file system."""
2773
- selected_items = self.fileTree.selectedItems()
2774
-
2975
+ """Delete selected leaf images from disk and remove them from the blink list."""
2976
+ selected_items = [it for it in self.fileTree.selectedItems() if it and it.childCount() == 0]
2775
2977
  if not selected_items:
2776
- QMessageBox.warning(self, self.tr("Warning"), self.tr("No items selected for deletion."))
2978
+ QMessageBox.warning(self, self.tr("Warning"), self.tr("No individual image items selected for deletion."))
2777
2979
  return
2778
2980
 
2779
- # Confirmation dialog
2780
2981
  reply = QMessageBox.question(
2781
2982
  self,
2782
- self.tr('Confirm Deletion'),
2983
+ self.tr("Confirm Deletion"),
2783
2984
  self.tr("Are you sure you want to permanently delete {0} selected images? This action is irreversible.").format(len(selected_items)),
2784
2985
  QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
2785
2986
  QMessageBox.StandardButton.No
2786
2987
  )
2988
+ if reply != QMessageBox.StandardButton.Yes:
2989
+ return
2787
2990
 
2788
2991
  removed_indices = []
2789
- if reply == QMessageBox.StandardButton.Yes:
2790
- for item in selected_items:
2791
- file_name = item.text(0).lstrip("⚠️ ")
2792
- file_path = next((path for path in self.image_paths if os.path.basename(path) == file_name), None)
2793
- if file_path:
2794
- try:
2795
- idx = self.image_paths.index(file_path)
2796
- removed_indices.append(idx) # collect BEFORE mutation
2797
- ...
2798
- os.remove(file_path)
2799
- except Exception as e:
2800
- ...
2801
- # Remove from widgets
2802
- for item in selected_items:
2803
- parent = item.parent() or self.fileTree.invisibleRootItem()
2804
- parent.removeChild(item)
2805
-
2806
- # Purge arrays (descending order)
2807
- for idx in sorted(removed_indices, reverse=True):
2992
+ failures = []
2993
+
2994
+ # Snapshot first
2995
+ triplets = []
2996
+ for it in selected_items:
2997
+ p = self._leaf_path(it)
2998
+ if not p:
2999
+ continue
3000
+ try:
3001
+ idx = self.image_paths.index(p)
3002
+ except ValueError:
3003
+ continue
3004
+ triplets.append((idx, p, it))
3005
+
3006
+ for idx, path, it in triplets:
3007
+ try:
3008
+ os.remove(path)
3009
+ except Exception as e:
3010
+ failures.append((path, str(e)))
3011
+ continue
3012
+
3013
+ removed_indices.append(idx)
3014
+
3015
+ # remove from tree immediately (optional)
3016
+ parent = it.parent() or self.fileTree.invisibleRootItem()
3017
+ parent.removeChild(it)
3018
+
3019
+ # Purge arrays descending
3020
+ removed_indices = sorted(set(removed_indices), reverse=True)
3021
+ for idx in removed_indices:
3022
+ if 0 <= idx < len(self.image_paths):
2808
3023
  del self.image_paths[idx]
3024
+ if 0 <= idx < len(self.loaded_images):
2809
3025
  del self.loaded_images[idx]
2810
3026
 
2811
- # Clear preview
2812
- self.preview_label.clear()
2813
- self.preview_label.setText(self.tr('No image selected.'))
2814
- self.current_image = None
3027
+ # Clear preview safely
3028
+ self.preview_label.clear()
3029
+ self.preview_label.setText(self.tr("No image selected."))
3030
+ self.current_pixmap = None
2815
3031
 
2816
- # 🔁 refresh tree + metrics (no recompute)
3032
+ if removed_indices:
2817
3033
  self._after_list_changed(removed_indices)
2818
3034
 
3035
+ if failures:
3036
+ msg = self.tr("Deleted {0} file(s). {1} failed:").format(len(removed_indices), len(failures))
3037
+ for p, err in failures[:10]:
3038
+ msg += f"\n• {os.path.basename(p)} – {err}"
3039
+ QMessageBox.warning(self, self.tr("Delete Selected Items"), msg)
3040
+ else:
3041
+ QMessageBox.information(self, self.tr("Delete Selected Items"), self.tr("Deleted {0} item(s).").format(len(removed_indices)))
3042
+
3043
+
2819
3044
  def eventFilter(self, source, event):
2820
3045
  """Handle mouse events for dragging."""
2821
3046
  if source == self.scroll_area.viewport():
@@ -2878,16 +3103,14 @@ class BlinkTab(QWidget):
2878
3103
  self.on_item_clicked(cur, 0)
2879
3104
 
2880
3105
  def convert_to_qimage(self, img_array):
2881
- """Convert numpy image array to QImage."""
2882
- # 1) Bring everything into a uint8 (0–255) array
2883
3106
  if img_array.dtype == np.uint8:
2884
3107
  arr8 = img_array
2885
3108
  elif img_array.dtype == np.uint16:
2886
- # downscale 16-bit → 8-bit
2887
3109
  arr8 = (img_array.astype(np.float32) / 65535.0 * 255.0).clip(0,255).astype(np.uint8)
2888
3110
  else:
2889
- # assume float in [0..1]
2890
- arr8 = (img_array.clip(0.0, 1.0) * 255.0).astype(np.uint8)
3111
+ # display-only normalize floats outside 0..1
3112
+ f01 = self._ensure_float01(img_array)
3113
+ arr8 = (f01 * 255.0).astype(np.uint8)
2891
3114
 
2892
3115
  h, w = arr8.shape[:2]
2893
3116
  buffer = arr8.tobytes()