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.
- copick_shared_ui/__init__.py +12 -0
- copick_shared_ui/ui/__init__.py +0 -0
- copick_shared_ui/ui/edit_object_types_dialog.py +797 -0
- copick_shared_ui/util/__init__.py +0 -0
- copick_shared_ui/util/validation.py +79 -0
- copick_shared_ui-0.0.1.dist-info/METADATA +109 -0
- copick_shared_ui-0.0.1.dist-info/RECORD +9 -0
- copick_shared_ui-0.0.1.dist-info/WHEEL +4 -0
- copick_shared_ui-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|