copick-shared-ui 0.0.1__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.
@@ -0,0 +1,12 @@
1
+ __version__ = "0.0.1"
2
+
3
+ from copick_shared_ui.ui.edit_object_types_dialog import EditObjectTypesDialog, ColorButton
4
+ from copick_shared_ui.util.validation import validate_copick_name, get_invalid_characters, generate_smart_copy_name
5
+
6
+ __all__ = (
7
+ "EditObjectTypesDialog",
8
+ "ColorButton",
9
+ "validate_copick_name",
10
+ "get_invalid_characters",
11
+ "generate_smart_copy_name",
12
+ )
File without changes
@@ -0,0 +1,797 @@
1
+ """
2
+ Dialog for editing and managing PickableObject types in the copick configuration
3
+ """
4
+
5
+ from typing import List, Tuple
6
+
7
+ from copick.models import PickableObject
8
+ from qtpy.QtCore import Qt
9
+ from qtpy.QtGui import QFont
10
+ from qtpy.QtWidgets import (
11
+ QCheckBox,
12
+ QColorDialog,
13
+ QDialog,
14
+ QDialogButtonBox,
15
+ QDoubleSpinBox,
16
+ QFormLayout,
17
+ QGridLayout,
18
+ QGroupBox,
19
+ QHBoxLayout,
20
+ QHeaderView,
21
+ QLabel,
22
+ QLineEdit,
23
+ QMessageBox,
24
+ QPushButton,
25
+ QSpinBox,
26
+ QTableWidget,
27
+ QTableWidgetItem,
28
+ QVBoxLayout,
29
+ QWidget,
30
+ )
31
+
32
+ from copick_shared_ui.util.validation import validate_copick_name
33
+
34
+
35
+ class ColorButton(QPushButton):
36
+ """Button that displays and allows selection of a color"""
37
+
38
+ def __init__(self, color: Tuple[int, int, int, int] = (100, 100, 100, 255), parent=None):
39
+ super().__init__(parent)
40
+ self._color = color
41
+ self.setMaximumSize(30, 30)
42
+ self.setMinimumSize(30, 30)
43
+ self.clicked.connect(self._select_color)
44
+ self._update_appearance()
45
+
46
+ def _update_appearance(self):
47
+ """Update button appearance to show current color"""
48
+ r, g, b, a = self._color
49
+ self.setStyleSheet(
50
+ f"""
51
+ QPushButton {{
52
+ background-color: rgba({r}, {g}, {b}, {a});
53
+ border: 2px solid #666;
54
+ border-radius: 4px;
55
+ }}
56
+ QPushButton:hover {{
57
+ border: 2px solid #333;
58
+ }}
59
+ """,
60
+ )
61
+ self.setToolTip(f"RGBA: ({r}, {g}, {b}, {a})")
62
+
63
+ def _select_color(self):
64
+ """Open color dialog to select new color"""
65
+ from qtpy.QtGui import QColor
66
+
67
+ current_color = QColor(*self._color[:3]) # RGB only for QColor
68
+ color = QColorDialog.getColor(current_color, self, "Select Object Color")
69
+ if color.isValid():
70
+ self._color = (color.red(), color.green(), color.blue(), self._color[3]) # Keep alpha
71
+ self._update_appearance()
72
+
73
+ def get_color(self) -> Tuple[int, int, int, int]:
74
+ """Get the current color"""
75
+ return self._color
76
+
77
+ def set_color(self, color: Tuple[int, int, int, int]):
78
+ """Set the current color"""
79
+ self._color = color
80
+ self._update_appearance()
81
+
82
+
83
+ class EditObjectTypesDialog(QDialog):
84
+ """Dialog for editing and managing PickableObject types"""
85
+
86
+ def __init__(self, parent=None, existing_objects=None):
87
+ super().__init__(parent)
88
+ self._existing_objects = list(existing_objects) if existing_objects else []
89
+ self._original_objects = [obj.model_copy(deep=True) for obj in self._existing_objects] # Keep original state
90
+ self._selected_object = None
91
+ self._editing_mode = False # True when editing, False when adding new
92
+
93
+ self.setModal(True)
94
+ self.setWindowTitle("Edit Object Types")
95
+ self.setMinimumSize(800, 600)
96
+ self.resize(900, 700)
97
+
98
+ self._setup_ui()
99
+ self._connect_signals()
100
+ self._populate_objects_table()
101
+ self._reset_form()
102
+ self._update_button_states()
103
+
104
+ def _setup_ui(self):
105
+ """Setup the dialog UI"""
106
+ main_layout = QVBoxLayout()
107
+ main_layout.setContentsMargins(12, 12, 12, 12)
108
+ main_layout.setSpacing(12)
109
+
110
+ # Header
111
+ header = QLabel("✏️ Edit Object Types")
112
+ header_font = QFont()
113
+ header_font.setBold(True)
114
+ header_font.setPointSize(14)
115
+ header.setFont(header_font)
116
+ main_layout.addWidget(header)
117
+
118
+ # Info label
119
+ info_label = QLabel("Select an object type from the table to edit, or create a new one using the form below.")
120
+ info_label.setStyleSheet("color: gray; font-style: italic;")
121
+ main_layout.addWidget(info_label)
122
+
123
+ # Objects table with management buttons
124
+ self._create_objects_table()
125
+ main_layout.addWidget(self._objects_group)
126
+
127
+ # Form for editing/adding
128
+ self._create_object_form()
129
+ main_layout.addWidget(self._form_group)
130
+
131
+ # Dialog buttons
132
+ self._button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
133
+ self._ok_button = self._button_box.button(QDialogButtonBox.Ok)
134
+ self._ok_button.setText("Save & Close")
135
+ self._ok_button.setEnabled(False) # Initially disabled until changes are made
136
+ self._ok_button.setStyleSheet(
137
+ """
138
+ QPushButton {
139
+ background-color: #4A90E2;
140
+ color: white;
141
+ border: none;
142
+ border-radius: 4px;
143
+ padding: 6px 12px;
144
+ font-weight: bold;
145
+ min-width: 80px;
146
+ }
147
+ QPushButton:hover {
148
+ background-color: #357ABD;
149
+ }
150
+ QPushButton:disabled {
151
+ background-color: #ccc;
152
+ color: #888;
153
+ }
154
+ """,
155
+ )
156
+
157
+ main_layout.addWidget(self._button_box)
158
+ self.setLayout(main_layout)
159
+
160
+ def _create_objects_table(self):
161
+ """Create the objects management table"""
162
+ self._objects_group = QGroupBox("Existing Object Types")
163
+ group_layout = QVBoxLayout()
164
+
165
+ # Table management buttons
166
+ table_buttons_layout = QHBoxLayout()
167
+ table_buttons_layout.setContentsMargins(0, 0, 0, 5)
168
+
169
+ self._edit_button = QPushButton("✏️ Edit Selected")
170
+ self._edit_button.setEnabled(False)
171
+ self._edit_button.setToolTip("Edit the selected object type")
172
+
173
+ self._delete_button = QPushButton("❌ Delete Selected")
174
+ self._delete_button.setEnabled(False)
175
+ self._delete_button.setToolTip("Delete the selected object type")
176
+
177
+ self._new_button = QPushButton("📄 Add New")
178
+ self._new_button.setToolTip("Create a new object type")
179
+
180
+ table_buttons_layout.addWidget(self._edit_button)
181
+ table_buttons_layout.addWidget(self._delete_button)
182
+ table_buttons_layout.addStretch()
183
+ table_buttons_layout.addWidget(self._new_button)
184
+
185
+ group_layout.addLayout(table_buttons_layout)
186
+
187
+ # Objects table
188
+ self._objects_table = QTableWidget()
189
+ self._objects_table.setColumnCount(6)
190
+ self._objects_table.setHorizontalHeaderLabels(["Name", "Type", "Label", "Color", "EMDB/PDB", "Additional Info"])
191
+
192
+ # Configure table appearance
193
+ self._objects_table.setAlternatingRowColors(False) # Disable alternating colors to use napari default
194
+ self._objects_table.setSelectionBehavior(QTableWidget.SelectRows)
195
+ self._objects_table.setSelectionMode(QTableWidget.SingleSelection)
196
+ self._objects_table.setEditTriggers(QTableWidget.NoEditTriggers)
197
+ self._objects_table.setMaximumHeight(200)
198
+
199
+ # Configure column widths
200
+ header = self._objects_table.horizontalHeader()
201
+ header.setSectionResizeMode(0, QHeaderView.ResizeToContents) # Name
202
+ header.setSectionResizeMode(1, QHeaderView.ResizeToContents) # Type
203
+ header.setSectionResizeMode(2, QHeaderView.ResizeToContents) # Label
204
+ header.setSectionResizeMode(3, QHeaderView.Fixed) # Color
205
+ header.resizeSection(3, 60)
206
+ header.setSectionResizeMode(4, QHeaderView.ResizeToContents) # EMDB/PDB
207
+ header.setSectionResizeMode(5, QHeaderView.Stretch) # Additional Info
208
+
209
+ group_layout.addWidget(self._objects_table)
210
+ self._objects_group.setLayout(group_layout)
211
+
212
+ def _create_object_form(self):
213
+ """Create the object form for editing/adding"""
214
+ self._form_group = QGroupBox("Object Configuration")
215
+ main_layout = QVBoxLayout()
216
+
217
+ # Form status label
218
+ self._form_status = QLabel("Ready to add new object")
219
+ self._form_status.setStyleSheet("font-weight: bold; color: #4A90E2; padding: 5px;")
220
+ main_layout.addWidget(self._form_status)
221
+
222
+ # Grid layout for two columns
223
+ grid_layout = QGridLayout()
224
+ grid_layout.setColumnStretch(0, 1)
225
+ grid_layout.setColumnStretch(1, 1)
226
+ grid_layout.setHorizontalSpacing(20)
227
+
228
+ # Left column - Basic properties
229
+ left_group = QGroupBox("Basic Properties")
230
+ left_layout = QFormLayout()
231
+
232
+ # Object name
233
+ self._name_edit = QLineEdit()
234
+ self._name_edit.setPlaceholderText("Enter object name (e.g., 'ribosome', 'membrane')")
235
+ self._name_edit.setToolTip("Unique name for this pickable object type")
236
+ left_layout.addRow("Name*:", self._name_edit)
237
+
238
+ # Name validation label
239
+ self._name_validation = QLabel()
240
+ self._name_validation.setStyleSheet(
241
+ """
242
+ QLabel {
243
+ color: #d32f2f;
244
+ font-size: 10px;
245
+ padding: 2px;
246
+ background-color: rgba(211, 47, 47, 0.1);
247
+ border: 1px solid rgba(211, 47, 47, 0.3);
248
+ border-radius: 3px;
249
+ }
250
+ """,
251
+ )
252
+ self._name_validation.setWordWrap(True)
253
+ self._name_validation.hide()
254
+ left_layout.addRow("", self._name_validation)
255
+
256
+ # Is particle checkbox
257
+ self._is_particle_cb = QCheckBox("Is Particle")
258
+ self._is_particle_cb.setChecked(True)
259
+ self._is_particle_cb.setToolTip(
260
+ "Check if this object should be represented by points, uncheck for segmentation masks",
261
+ )
262
+ left_layout.addRow("Type:", self._is_particle_cb)
263
+
264
+ # Label (numeric ID)
265
+ self._label_spin = QSpinBox()
266
+ self._label_spin.setRange(1, 9999)
267
+ self._label_spin.setValue(1)
268
+ self._label_spin.setToolTip("Unique numeric identifier for this object type")
269
+ left_layout.addRow("Label*:", self._label_spin)
270
+
271
+ # Label validation
272
+ self._label_validation = QLabel()
273
+ self._label_validation.setStyleSheet(
274
+ """
275
+ QLabel {
276
+ color: #d32f2f;
277
+ font-size: 10px;
278
+ padding: 2px;
279
+ background-color: rgba(211, 47, 47, 0.1);
280
+ border: 1px solid rgba(211, 47, 47, 0.3);
281
+ border-radius: 3px;
282
+ }
283
+ """,
284
+ )
285
+ self._label_validation.setWordWrap(True)
286
+ self._label_validation.hide()
287
+ left_layout.addRow("", self._label_validation)
288
+
289
+ # Color selection
290
+ color_widget = QWidget()
291
+ color_layout = QHBoxLayout()
292
+ color_layout.setContentsMargins(0, 0, 0, 0)
293
+ color_layout.setSpacing(5)
294
+
295
+ self._color_button = ColorButton()
296
+ color_layout.addWidget(self._color_button)
297
+ color_layout.addWidget(QLabel("Click to change color"))
298
+ color_layout.addStretch()
299
+ color_widget.setLayout(color_layout)
300
+
301
+ left_layout.addRow("Color:", color_widget)
302
+ left_group.setLayout(left_layout)
303
+
304
+ # Right column - Optional properties
305
+ right_group = QGroupBox("Optional Properties")
306
+ right_layout = QFormLayout()
307
+
308
+ # EMDB ID
309
+ self._emdb_edit = QLineEdit()
310
+ self._emdb_edit.setPlaceholderText("e.g., EMD-1234")
311
+ self._emdb_edit.setToolTip("EMDB ID for this object type")
312
+ right_layout.addRow("EMDB ID:", self._emdb_edit)
313
+
314
+ # PDB ID
315
+ self._pdb_edit = QLineEdit()
316
+ self._pdb_edit.setPlaceholderText("e.g., 1ABC")
317
+ self._pdb_edit.setToolTip("PDB ID for this object type")
318
+ right_layout.addRow("PDB ID:", self._pdb_edit)
319
+
320
+ # Identifier (GO/UniProt)
321
+ self._identifier_edit = QLineEdit()
322
+ self._identifier_edit.setPlaceholderText("e.g., GO:0005840 or P12345")
323
+ self._identifier_edit.setToolTip("Gene Ontology ID or UniProtKB accession")
324
+ right_layout.addRow("Identifier:", self._identifier_edit)
325
+
326
+ # Map threshold
327
+ self._threshold_spin = QDoubleSpinBox()
328
+ self._threshold_spin.setRange(-9999.0, 9999.0)
329
+ self._threshold_spin.setDecimals(3)
330
+ self._threshold_spin.setValue(0.0)
331
+ self._threshold_spin.setSpecialValueText("None")
332
+ self._threshold_spin.setToolTip("Threshold for isosurface rendering (set to minimum for None)")
333
+ right_layout.addRow("Map Threshold:", self._threshold_spin)
334
+
335
+ # Radius
336
+ self._radius_spin = QDoubleSpinBox()
337
+ self._radius_spin.setRange(0.1, 1000.0)
338
+ self._radius_spin.setDecimals(1)
339
+ self._radius_spin.setValue(10.0)
340
+ self._radius_spin.setSpecialValueText("None")
341
+ self._radius_spin.setToolTip("Radius for particle display (set to minimum for None)")
342
+ right_layout.addRow("Radius (Å):", self._radius_spin)
343
+
344
+ right_group.setLayout(right_layout)
345
+
346
+ # Add groups to grid
347
+ grid_layout.addWidget(left_group, 0, 0)
348
+ grid_layout.addWidget(right_group, 0, 1)
349
+
350
+ # Form action buttons
351
+ form_buttons_layout = QHBoxLayout()
352
+ form_buttons_layout.setContentsMargins(0, 10, 0, 0)
353
+
354
+ self._apply_button = QPushButton("✅ Save Object")
355
+ self._apply_button.setEnabled(False)
356
+ self._apply_button.setToolTip("Save the current object to the list")
357
+
358
+ self._cancel_edit_button = QPushButton("❌ Cancel")
359
+ self._cancel_edit_button.setEnabled(False)
360
+ self._cancel_edit_button.setToolTip("Cancel current editing operation")
361
+
362
+ form_buttons_layout.addWidget(self._apply_button)
363
+ form_buttons_layout.addWidget(self._cancel_edit_button)
364
+ form_buttons_layout.addStretch()
365
+
366
+ main_layout.addLayout(grid_layout)
367
+ main_layout.addLayout(form_buttons_layout)
368
+ self._form_group.setLayout(main_layout)
369
+
370
+ def _connect_signals(self):
371
+ """Connect widget signals"""
372
+ # Dialog buttons
373
+ self._button_box.accepted.connect(self.accept)
374
+ self._button_box.rejected.connect(self.reject)
375
+
376
+ # Table management buttons
377
+ self._edit_button.clicked.connect(self._edit_selected_object)
378
+ self._delete_button.clicked.connect(self._delete_selected_object)
379
+ self._new_button.clicked.connect(self._new_object)
380
+
381
+ # Form buttons
382
+ self._apply_button.clicked.connect(self._apply_changes)
383
+ self._cancel_edit_button.clicked.connect(self._cancel_edit)
384
+
385
+ # Table selection
386
+ self._objects_table.selectionModel().selectionChanged.connect(self._on_table_selection_changed)
387
+ self._objects_table.doubleClicked.connect(self._edit_selected_object)
388
+
389
+ # Form validation
390
+ self._name_edit.textChanged.connect(self._validate_form)
391
+ self._label_spin.valueChanged.connect(self._validate_form)
392
+
393
+ def _populate_objects_table(self):
394
+ """Populate the objects table"""
395
+ self._objects_table.setRowCount(len(self._existing_objects))
396
+
397
+ for row, obj in enumerate(self._existing_objects):
398
+ # Name
399
+ name_item = QTableWidgetItem(obj.name)
400
+ name_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
401
+ self._objects_table.setItem(row, 0, name_item)
402
+
403
+ # Type
404
+ type_text = "Particle" if obj.is_particle else "Segmentation"
405
+ type_item = QTableWidgetItem(type_text)
406
+ type_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
407
+ self._objects_table.setItem(row, 1, type_item)
408
+
409
+ # Label
410
+ label_text = str(obj.label) if obj.label is not None else "None"
411
+ label_item = QTableWidgetItem(label_text)
412
+ label_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
413
+ self._objects_table.setItem(row, 2, label_item)
414
+
415
+ # Color
416
+ if obj.color:
417
+ r, g, b, a = obj.color
418
+ color_widget = QWidget()
419
+ color_layout = QHBoxLayout()
420
+ color_layout.setContentsMargins(5, 2, 5, 2)
421
+ color_button = QPushButton()
422
+ color_button.setEnabled(False)
423
+ color_button.setMaximumSize(20, 20)
424
+ color_button.setStyleSheet(
425
+ f"""
426
+ QPushButton {{
427
+ background-color: rgba({r}, {g}, {b}, {a});
428
+ border: 1px solid #666;
429
+ border-radius: 2px;
430
+ }}
431
+ """,
432
+ )
433
+ color_layout.addWidget(color_button)
434
+ color_layout.addStretch()
435
+ color_widget.setLayout(color_layout)
436
+ self._objects_table.setCellWidget(row, 3, color_widget)
437
+ else:
438
+ color_item = QTableWidgetItem("None")
439
+ color_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
440
+ self._objects_table.setItem(row, 3, color_item)
441
+
442
+ # EMDB/PDB
443
+ ids = []
444
+ if obj.emdb_id:
445
+ ids.append(f"EMDB:{obj.emdb_id}")
446
+ if obj.pdb_id:
447
+ ids.append(f"PDB:{obj.pdb_id}")
448
+ id_text = ", ".join(ids) if ids else "None"
449
+ id_item = QTableWidgetItem(id_text)
450
+ id_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
451
+ self._objects_table.setItem(row, 4, id_item)
452
+
453
+ # Additional Info
454
+ info_parts = []
455
+ if obj.identifier:
456
+ info_parts.append(f"ID:{obj.identifier}")
457
+ if obj.map_threshold is not None:
458
+ info_parts.append(f"Threshold:{obj.map_threshold}")
459
+ if obj.radius is not None:
460
+ info_parts.append(f"Radius:{obj.radius}Å")
461
+ info_text = ", ".join(info_parts) if info_parts else "None"
462
+ info_item = QTableWidgetItem(info_text)
463
+ info_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
464
+ self._objects_table.setItem(row, 5, info_item)
465
+
466
+ def _on_table_selection_changed(self):
467
+ """Handle table selection changes"""
468
+ self._update_button_states()
469
+
470
+ def _update_button_states(self):
471
+ """Update button states based on current state"""
472
+ has_selection = len(self._objects_table.selectionModel().selectedRows()) > 0
473
+ form_valid = self._is_form_valid()
474
+
475
+ self._edit_button.setEnabled(has_selection and not self._editing_mode)
476
+ self._delete_button.setEnabled(has_selection and not self._editing_mode)
477
+ self._new_button.setEnabled(not self._editing_mode)
478
+
479
+ # Form buttons
480
+ apply_enabled = self._editing_mode and form_valid
481
+ self._apply_button.setEnabled(apply_enabled)
482
+ self._cancel_edit_button.setEnabled(self._editing_mode)
483
+
484
+ def _edit_selected_object(self):
485
+ """Edit the currently selected object"""
486
+ selected_rows = self._objects_table.selectionModel().selectedRows()
487
+ if not selected_rows:
488
+ return
489
+
490
+ row = selected_rows[0].row()
491
+ if 0 <= row < len(self._existing_objects):
492
+ self._selected_object = self._existing_objects[row]
493
+ self._editing_mode = True
494
+ self._populate_form_from_object(self._selected_object)
495
+ self._form_status.setText(f"Editing: {self._selected_object.name}")
496
+ self._form_status.setStyleSheet("font-weight: bold; color: #FF8C00; padding: 5px;")
497
+ self._update_button_states()
498
+
499
+ def _delete_selected_object(self):
500
+ """Delete the currently selected object"""
501
+ selected_rows = self._objects_table.selectionModel().selectedRows()
502
+ if not selected_rows:
503
+ return
504
+
505
+ row = selected_rows[0].row()
506
+ if 0 <= row < len(self._existing_objects):
507
+ obj_to_delete = self._existing_objects[row]
508
+
509
+ # Confirm deletion
510
+ reply = QMessageBox.question(
511
+ self,
512
+ "Delete Object Type",
513
+ f"Are you sure you want to delete the object type '{obj_to_delete.name}'?\n\n"
514
+ f"This action cannot be undone.",
515
+ QMessageBox.Yes | QMessageBox.No,
516
+ QMessageBox.No,
517
+ )
518
+
519
+ if reply == QMessageBox.Yes:
520
+ deleted_name = obj_to_delete.name
521
+ self._existing_objects.pop(row)
522
+ self._populate_objects_table()
523
+ self._reset_form()
524
+ self._update_button_states()
525
+
526
+ # Show feedback and enable OK button
527
+ self._form_status.setText(f"✅ Object '{deleted_name}' deleted successfully")
528
+ self._form_status.setStyleSheet("font-weight: bold; color: #28a745; padding: 5px;")
529
+ self._ok_button.setEnabled(True)
530
+ self._ok_button.setStyleSheet(
531
+ """
532
+ QPushButton {
533
+ background-color: #28a745;
534
+ color: white;
535
+ border: none;
536
+ border-radius: 4px;
537
+ padding: 6px 12px;
538
+ font-weight: bold;
539
+ min-width: 80px;
540
+ }
541
+ QPushButton:hover {
542
+ background-color: #218838;
543
+ }
544
+ """
545
+ )
546
+
547
+ def _new_object(self):
548
+ """Start creating a new object"""
549
+ self._selected_object = None
550
+ self._editing_mode = True
551
+ self._reset_form(keep_editing_mode=True)
552
+ self._populate_initial_data()
553
+ self._form_status.setText("Creating new object")
554
+ self._form_status.setStyleSheet("font-weight: bold; color: #4A90E2; padding: 5px;")
555
+ self._update_button_states()
556
+ self._name_edit.setFocus()
557
+
558
+ def _apply_changes(self):
559
+ """Apply current form changes"""
560
+ if not self._is_form_valid():
561
+ return
562
+
563
+ new_object = self._create_object_from_form()
564
+ object_name = new_object.name
565
+
566
+ if self._selected_object:
567
+ # Update existing object
568
+ row = self._existing_objects.index(self._selected_object)
569
+ self._existing_objects[row] = new_object
570
+ action = "updated"
571
+ else:
572
+ # Add new object
573
+ self._existing_objects.append(new_object)
574
+ action = "added"
575
+
576
+ self._populate_objects_table()
577
+ self._reset_form()
578
+ self._update_button_states()
579
+
580
+ # Provide immediate visual feedback
581
+ self._form_status.setText(f"✅ Object '{object_name}' {action} successfully")
582
+ self._form_status.setStyleSheet("font-weight: bold; color: #28a745; padding: 5px;")
583
+
584
+ # Enable the OK button to indicate changes are ready to be saved
585
+ self._ok_button.setEnabled(True)
586
+ self._ok_button.setStyleSheet(
587
+ """
588
+ QPushButton {
589
+ background-color: #28a745;
590
+ color: white;
591
+ border: none;
592
+ border-radius: 4px;
593
+ padding: 6px 12px;
594
+ font-weight: bold;
595
+ min-width: 80px;
596
+ }
597
+ QPushButton:hover {
598
+ background-color: #218838;
599
+ }
600
+ """
601
+ )
602
+
603
+ def _cancel_edit(self):
604
+ """Cancel current editing operation"""
605
+ self._reset_form()
606
+ self._update_button_states()
607
+
608
+ def _reset_form(self, keep_editing_mode=False):
609
+ """Reset form to default state"""
610
+ self._selected_object = None
611
+ if not keep_editing_mode:
612
+ self._editing_mode = False
613
+ self._form_status.setText("Ready to add new object")
614
+ self._form_status.setStyleSheet("font-weight: bold; color: #4A90E2; padding: 5px;")
615
+
616
+ # Clear form fields
617
+ self._name_edit.clear()
618
+ self._is_particle_cb.setChecked(True)
619
+ self._label_spin.setValue(1)
620
+ self._color_button.set_color((100, 100, 100, 255))
621
+ self._emdb_edit.clear()
622
+ self._pdb_edit.clear()
623
+ self._identifier_edit.clear()
624
+ self._threshold_spin.setValue(self._threshold_spin.minimum())
625
+ self._radius_spin.setValue(self._radius_spin.minimum())
626
+
627
+ # Hide validation messages
628
+ self._name_validation.hide()
629
+ self._label_validation.hide()
630
+ self._name_edit.setStyleSheet("")
631
+ self._label_spin.setStyleSheet("")
632
+
633
+ def _populate_form_from_object(self, obj: PickableObject):
634
+ """Populate form fields from an object"""
635
+ self._name_edit.setText(obj.name)
636
+ self._is_particle_cb.setChecked(obj.is_particle)
637
+
638
+ if obj.label is not None:
639
+ self._label_spin.setValue(obj.label)
640
+
641
+ if obj.color:
642
+ self._color_button.set_color(obj.color)
643
+
644
+ self._emdb_edit.setText(obj.emdb_id or "")
645
+ self._pdb_edit.setText(obj.pdb_id or "")
646
+ self._identifier_edit.setText(obj.identifier or "")
647
+
648
+ if obj.map_threshold is not None:
649
+ self._threshold_spin.setValue(obj.map_threshold)
650
+ else:
651
+ self._threshold_spin.setValue(self._threshold_spin.minimum())
652
+
653
+ if obj.radius is not None:
654
+ self._radius_spin.setValue(obj.radius)
655
+ else:
656
+ self._radius_spin.setValue(self._radius_spin.minimum())
657
+
658
+ def _populate_initial_data(self):
659
+ """Populate initial data for new object"""
660
+ existing_labels = {obj.label for obj in self._existing_objects if obj.label is not None}
661
+ next_label = max(existing_labels) + 1 if existing_labels else 1
662
+ self._label_spin.setValue(next_label)
663
+
664
+ def _create_object_from_form(self) -> PickableObject:
665
+ """Create PickableObject from current form data"""
666
+ name = self._name_edit.text().strip()
667
+ is_particle = self._is_particle_cb.isChecked()
668
+ label = self._label_spin.value()
669
+ color = self._color_button.get_color()
670
+
671
+ # Handle optional fields
672
+ emdb_id = self._emdb_edit.text().strip() or None
673
+ pdb_id = self._pdb_edit.text().strip() or None
674
+ identifier = self._identifier_edit.text().strip() or None
675
+
676
+ # Handle threshold (None if at minimum)
677
+ threshold = None
678
+ if self._threshold_spin.value() != self._threshold_spin.minimum():
679
+ threshold = self._threshold_spin.value()
680
+
681
+ # Handle radius (None if at minimum)
682
+ radius = None
683
+ if self._radius_spin.value() != self._radius_spin.minimum():
684
+ radius = self._radius_spin.value()
685
+
686
+ return PickableObject(
687
+ name=name,
688
+ is_particle=is_particle,
689
+ label=label,
690
+ color=color,
691
+ emdb_id=emdb_id,
692
+ pdb_id=pdb_id,
693
+ identifier=identifier,
694
+ map_threshold=threshold,
695
+ radius=radius,
696
+ )
697
+
698
+ def _validate_form(self):
699
+ """Validate the current form state"""
700
+ name_text = self._name_edit.text().strip()
701
+
702
+ # Validate name
703
+ is_valid, _, error_msg = validate_copick_name(name_text)
704
+
705
+ # Check for uniqueness (excluding current object being edited)
706
+ existing_names = {obj.name.lower() for obj in self._existing_objects if obj != self._selected_object}
707
+
708
+ if is_valid and name_text.lower() in existing_names:
709
+ is_valid = False
710
+ error_msg = f"Object name '{name_text}' already exists. Please choose a different name."
711
+
712
+ # Update name validation display
713
+ if is_valid:
714
+ self._name_validation.hide()
715
+ self._name_edit.setStyleSheet("")
716
+ else:
717
+ self._name_validation.setText(error_msg)
718
+ self._name_validation.show()
719
+ self._name_edit.setStyleSheet(
720
+ """
721
+ QLineEdit {
722
+ border: 2px solid #d32f2f;
723
+ background-color: rgba(211, 47, 47, 0.05);
724
+ }
725
+ """,
726
+ )
727
+
728
+ # Validate label uniqueness
729
+ label_value = self._label_spin.value()
730
+ existing_labels = {
731
+ obj.label for obj in self._existing_objects if obj != self._selected_object and obj.label is not None
732
+ }
733
+
734
+ label_valid = label_value not in existing_labels
735
+
736
+ if label_valid:
737
+ self._label_validation.hide()
738
+ self._label_spin.setStyleSheet("")
739
+ else:
740
+ error_msg = f"Label {label_value} is already in use. Please choose a different label."
741
+ self._label_validation.setText(error_msg)
742
+ self._label_validation.show()
743
+ self._label_spin.setStyleSheet(
744
+ """
745
+ QSpinBox {
746
+ border: 2px solid #d32f2f;
747
+ background-color: rgba(211, 47, 47, 0.05);
748
+ }
749
+ """,
750
+ )
751
+
752
+ self._update_button_states()
753
+ self.adjustSize()
754
+
755
+ def _is_form_valid(self) -> bool:
756
+ """Check if the current form state is valid"""
757
+ name_text = self._name_edit.text().strip()
758
+ name_valid, _, _ = validate_copick_name(name_text)
759
+
760
+ # Check name uniqueness
761
+ existing_names = {obj.name.lower() for obj in self._existing_objects if obj != self._selected_object}
762
+ if name_valid and name_text.lower() in existing_names:
763
+ name_valid = False
764
+
765
+ # Check label uniqueness
766
+ label_value = self._label_spin.value()
767
+ existing_labels = {
768
+ obj.label for obj in self._existing_objects if obj != self._selected_object and obj.label is not None
769
+ }
770
+ label_valid = label_value not in existing_labels
771
+
772
+ return name_valid and label_valid and bool(name_text)
773
+
774
+ def get_objects(self) -> List[PickableObject]:
775
+ """Get the current list of objects"""
776
+ return self._existing_objects
777
+
778
+ def has_changes(self) -> bool:
779
+ """Check if there are any changes compared to the original objects"""
780
+ if len(self._existing_objects) != len(self._original_objects):
781
+ return True
782
+
783
+ for current, original in zip(self._existing_objects, self._original_objects, strict=False):
784
+ if (
785
+ current.name != original.name
786
+ or current.is_particle != original.is_particle
787
+ or current.label != original.label
788
+ or current.color != original.color
789
+ or current.emdb_id != original.emdb_id
790
+ or current.pdb_id != original.pdb_id
791
+ or current.identifier != original.identifier
792
+ or current.map_threshold != original.map_threshold
793
+ or current.radius != original.radius
794
+ ):
795
+ return True
796
+
797
+ return False
File without changes
@@ -0,0 +1,79 @@
1
+ """
2
+ Validation utilities for copick entity names using copick.util.escape rules
3
+ """
4
+ import re
5
+ from typing import Tuple
6
+
7
+
8
+ def validate_copick_name(input_str: str) -> Tuple[bool, str, str]:
9
+ """
10
+ Validate a string for use as copick object name, user_id or session_id.
11
+
12
+ Args:
13
+ input_str: The input string to validate
14
+
15
+ Returns:
16
+ Tuple of (is_valid, sanitized_name, error_message)
17
+ - is_valid: True if the original string is valid
18
+ - sanitized_name: The sanitized version of the input
19
+ - error_message: Error message if invalid, empty string if valid
20
+ """
21
+ if not input_str:
22
+ return False, "", "Name cannot be empty"
23
+
24
+ # Define invalid characters pattern from copick.util.escape
25
+ # Invalid: <>:"/\|?* (Windows), control chars, spaces, and underscores
26
+ invalid_chars = r'[<>:"/\\|?*\x00-\x1F\x7F\s_]'
27
+
28
+ # Check if string contains invalid characters
29
+ has_invalid = bool(re.search(invalid_chars, input_str))
30
+
31
+ # Create sanitized version
32
+ sanitized = re.sub(invalid_chars, "-", input_str)
33
+ sanitized = sanitized.strip("-")
34
+
35
+ if sanitized == "":
36
+ return False, "", "Name cannot consist only of invalid characters"
37
+
38
+ if has_invalid:
39
+ # Get list of invalid characters found
40
+ invalid_found = set(re.findall(invalid_chars, input_str))
41
+ invalid_list = ", ".join(f"'{char}'" if char != " " else "'space'" for char in sorted(invalid_found))
42
+ error_msg = f"Invalid characters: {invalid_list}"
43
+ return False, sanitized, error_msg
44
+
45
+ return True, input_str, ""
46
+
47
+
48
+ def get_invalid_characters(input_str: str) -> list:
49
+ """
50
+ Get list of invalid characters in the input string.
51
+
52
+ Args:
53
+ input_str: The input string to check
54
+
55
+ Returns:
56
+ List of invalid characters found
57
+ """
58
+ invalid_chars = r'[<>:"/\\|?*\x00-\x1F\x7F\s_]'
59
+ return list(set(re.findall(invalid_chars, input_str)))
60
+
61
+
62
+ def generate_smart_copy_name(base_name: str, existing_names: list) -> str:
63
+ """
64
+ Generate a smart copy name with auto-increment.
65
+ Always uses -copy1, -copy2, etc. format.
66
+
67
+ Args:
68
+ base_name: The original name to copy
69
+ existing_names: List of existing names to check against
70
+
71
+ Returns:
72
+ New unique name with -copy suffix and number
73
+ """
74
+ counter = 1
75
+ while True:
76
+ candidate = f"{base_name}-copy{counter}"
77
+ if candidate not in existing_names:
78
+ return candidate
79
+ counter += 1
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: copick-shared-ui
3
+ Version: 0.0.1
4
+ Summary: Shared UI components for copick visualization plugins
5
+ Project-URL: Repository, https://github.com/copick/copick-shared-ui
6
+ Project-URL: Issues, https://github.com/copick/copick-shared-ui/issues
7
+ Project-URL: Documentation, https://github.com/copick/copick-shared-ui#README.md
8
+ Project-URL: Bug Tracker, https://github.com/copick/copick-shared-ui/issues
9
+ Project-URL: Source Code, https://github.com/copick/copick-shared-ui
10
+ Project-URL: User Support, https://github.com/copick/copick-shared-ui/issues
11
+ Author-email: "Utz H. Ermel" <utz@ermel.me>, Kyle Harrington <czi@kyleharrington.com>
12
+ License: MIT License
13
+
14
+ Copyright (c) 2025 Utz H. Ermel
15
+
16
+ Permission is hereby granted, free of charge, to any person obtaining a copy
17
+ of this software and associated documentation files (the "Software"), to deal
18
+ in the Software without restriction, including without limitation the rights
19
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
20
+ copies of the Software, and to permit persons to whom the Software is
21
+ furnished to do so, subject to the following conditions:
22
+
23
+ The above copyright notice and this permission notice shall be included in all
24
+ copies or substantial portions of the Software.
25
+
26
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
27
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
28
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
29
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
30
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
32
+ SOFTWARE.
33
+ License-File: LICENSE
34
+ Keywords: annotation,copick,cryo-et,cryoet,qt,tomography,ui
35
+ Classifier: Development Status :: 2 - Pre-Alpha
36
+ Classifier: Intended Audience :: Developers
37
+ Classifier: License :: OSI Approved :: MIT License
38
+ Classifier: Operating System :: OS Independent
39
+ Classifier: Programming Language :: Python
40
+ Classifier: Programming Language :: Python :: 3
41
+ Classifier: Programming Language :: Python :: 3 :: Only
42
+ Classifier: Programming Language :: Python :: 3.9
43
+ Classifier: Programming Language :: Python :: 3.10
44
+ Classifier: Programming Language :: Python :: 3.11
45
+ Classifier: Programming Language :: Python :: 3.12
46
+ Classifier: Programming Language :: Python :: 3.13
47
+ Classifier: Programming Language :: Python :: Implementation :: CPython
48
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
49
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
50
+ Requires-Python: >=3.9
51
+ Requires-Dist: copick>=1.5.0
52
+ Requires-Dist: pydantic>=2
53
+ Requires-Dist: qtpy
54
+ Provides-Extra: dev
55
+ Requires-Dist: black>=25.1.0; extra == 'dev'
56
+ Requires-Dist: hatch-vcs>=0.4.0; extra == 'dev'
57
+ Requires-Dist: hatchling>=1.25.0; extra == 'dev'
58
+ Requires-Dist: pre-commit>=4.2.0; extra == 'dev'
59
+ Requires-Dist: ruff>=0.12.0; extra == 'dev'
60
+ Provides-Extra: testing
61
+ Requires-Dist: pyqt6; extra == 'testing'
62
+ Requires-Dist: pytest; extra == 'testing'
63
+ Requires-Dist: pytest-cov; extra == 'testing'
64
+ Requires-Dist: pytest-qt; extra == 'testing'
65
+ Requires-Dist: tox; extra == 'testing'
66
+ Requires-Dist: tox-gh-actions; extra == 'testing'
67
+ Requires-Dist: tox-uv; extra == 'testing'
68
+ Description-Content-Type: text/markdown
69
+
70
+ # copick-shared-ui
71
+
72
+ Shared UI components for copick visualization plugins.
73
+
74
+ This package provides reusable Qt-based UI components that can be used across different copick visualization plugins
75
+ (napari-copick, chimerax-copick, etc.).
76
+
77
+ ## Installation
78
+
79
+ ```bash
80
+ uv pip install copick-shared-ui
81
+ ```
82
+
83
+ ## Usage
84
+
85
+ ```python
86
+ from copick_shared_ui import EditObjectTypesDialog, validate_copick_name
87
+
88
+ # Use the object types editor
89
+ dialog = EditObjectTypesDialog(parent=None, existing_objects=my_objects)
90
+ if dialog.exec_() == QDialog.Accepted:
91
+ updated_objects = dialog.get_objects()
92
+
93
+ # Validate copick names
94
+ is_valid, sanitized, error_msg = validate_copick_name("my-object-name")
95
+ ```
96
+
97
+ ## Components
98
+
99
+ ### EditObjectTypesDialog
100
+
101
+ A dialog for managing copick PickableObject types with features:
102
+ - Add, edit, and delete object types
103
+ - Real-time validation with visual feedback
104
+ - Color selection and management
105
+ - Support for all `copick.PickableObject` properties (EMDB/PDB IDs, thresholds, etc.)
106
+
107
+ ### Validation
108
+
109
+ Utilities for validating copick entity names according to copick naming conventions.
@@ -0,0 +1,9 @@
1
+ copick_shared_ui/__init__.py,sha256=ais7RrMw_Z67Ayu2f5XcNTG2-v2fBOES2g7b7XXf-Tk,384
2
+ copick_shared_ui/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ copick_shared_ui/ui/edit_object_types_dialog.py,sha256=tLCuCy9mEW_CVkWpRltLgysIFTvM-jyqUgbntSjarV0,30362
4
+ copick_shared_ui/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ copick_shared_ui/util/validation.py,sha256=2zIGJ2h1YIB6z-BAWRnNvh3RfOsIudCO4eeLXom8K7Q,2476
6
+ copick_shared_ui-0.0.1.dist-info/METADATA,sha256=7H6WG2DfaQ9TupLW2U3CtY2Ytcv3NaJS-2l-7zUfi0E,4578
7
+ copick_shared_ui-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ copick_shared_ui-0.0.1.dist-info/licenses/LICENSE,sha256=_qWGIH60iOE2dQ9QtMKciaXjuqfoRivj03T3iChJy8U,1068
9
+ copick_shared_ui-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Utz H. Ermel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.