cnotebook 2.1.0__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.
cnotebook/grid/grid.py ADDED
@@ -0,0 +1,1649 @@
1
+ """MolGrid class for displaying molecules in an interactive grid."""
2
+
3
+ import json
4
+ import sys
5
+ import uuid
6
+ from html import escape
7
+ from pathlib import Path
8
+ from typing import Dict, Iterable, List, Optional, Union
9
+
10
+ import anywidget
11
+ import traitlets
12
+ from openeye import oechem
13
+
14
+ from cnotebook.context import CNotebookContext
15
+ from cnotebook.render import oemol_to_html
16
+
17
+ # Load List.js from local static file
18
+ _STATIC_DIR = Path(__file__).parent / "static"
19
+ _LIST_JS = (_STATIC_DIR / "list.min.js").read_text()
20
+
21
+ try:
22
+ from IPython.display import HTML, display
23
+ except ModuleNotFoundError:
24
+ pass
25
+
26
+
27
+ def _is_marimo() -> bool:
28
+ """Check if running in marimo environment.
29
+
30
+ Checks both if marimo is imported AND if we're in a marimo runtime.
31
+ """
32
+ if "marimo" not in sys.modules:
33
+ return False
34
+ try:
35
+ import marimo as mo
36
+ # Check if we're actually in a marimo runtime
37
+ return mo.running_in_notebook()
38
+ except (ImportError, AttributeError):
39
+ return False
40
+
41
+
42
+ class MolGridWidget(anywidget.AnyWidget):
43
+ """Jupyter widget for MolGrid selection synchronization.
44
+
45
+ This widget handles communication between the MolGrid iframe and the
46
+ Jupyter kernel for selection state and SMARTS query functionality.
47
+
48
+ :ivar grid_id: Unique identifier for this grid instance.
49
+ :ivar selection: JSON-encoded dictionary of selected molecule indices.
50
+ :ivar smarts_query: Current SMARTS query string from user input.
51
+ :ivar smarts_matches: JSON-encoded list of molecule indices matching the SMARTS query.
52
+ """
53
+
54
+ _esm = """
55
+ function render({ model, el }) {
56
+ const gridId = model.get("grid_id");
57
+ const globalName = "_MOLGRID_" + gridId;
58
+
59
+ // Store model globally so iframe can access it
60
+ window[globalName] = model;
61
+
62
+ // Listen for postMessage from iframe
63
+ window.addEventListener("message", (event) => {
64
+ if (event.data && event.data.gridId === gridId) {
65
+ if (event.data.type === "MOLGRID_SELECTION") {
66
+ model.set("selection", JSON.stringify(event.data.selection));
67
+ model.save_changes();
68
+ } else if (event.data.type === "MOLGRID_SMARTS_QUERY") {
69
+ model.set("smarts_query", event.data.query);
70
+ model.save_changes();
71
+ }
72
+ }
73
+ });
74
+
75
+ // Listen for SMARTS match results from Python
76
+ model.on("change:smarts_matches", () => {
77
+ const matches = model.get("smarts_matches");
78
+ // Broadcast to iframe
79
+ const iframe = document.querySelector("#molgrid-iframe-" + gridId);
80
+ if (iframe && iframe.contentWindow) {
81
+ iframe.contentWindow.postMessage({
82
+ type: "MOLGRID_SMARTS_RESULTS",
83
+ gridId: gridId,
84
+ matches: matches
85
+ }, "*");
86
+ }
87
+ });
88
+
89
+ // Listen for height updates from iframe
90
+ window.addEventListener("message", (event) => {
91
+ if (event.data && event.data.gridId === gridId && event.data.type === "MOLGRID_HEIGHT") {
92
+ const iframe = document.querySelector("#molgrid-iframe-" + gridId);
93
+ if (iframe) {
94
+ iframe.style.height = event.data.height + "px";
95
+ }
96
+ }
97
+ });
98
+
99
+ }
100
+
101
+ export default { render };
102
+ """
103
+
104
+ grid_id = traitlets.Unicode("").tag(sync=True)
105
+ selection = traitlets.Unicode("{}").tag(sync=True)
106
+ smarts_query = traitlets.Unicode("").tag(sync=True)
107
+ smarts_matches = traitlets.Unicode("[]").tag(sync=True)
108
+
109
+
110
+ # CSS matching backup styling
111
+ _CSS = '''
112
+ /* Base styles */
113
+ body {
114
+ overflow-x: hidden;
115
+ overflow-y: auto;
116
+ }
117
+
118
+ /* MolGrid Container */
119
+ .molgrid-container {
120
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
121
+ font-size: 14px;
122
+ color: #333;
123
+ max-width: 100%;
124
+ margin: 0 auto;
125
+ padding: 10px;
126
+ box-sizing: border-box;
127
+ overflow: visible;
128
+ }
129
+
130
+ /* Toolbar */
131
+ .molgrid-toolbar {
132
+ display: flex;
133
+ flex-wrap: wrap;
134
+ align-items: center;
135
+ gap: 10px 15px;
136
+ padding: 10px;
137
+ margin-bottom: 15px;
138
+ background: #f8f9fa;
139
+ border-radius: 6px;
140
+ border: 1px solid #e9ecef;
141
+ overflow: visible;
142
+ position: relative;
143
+ }
144
+
145
+ /* Responsive toolbar layout */
146
+ @media (max-width: 900px) {
147
+ .molgrid-toolbar {
148
+ gap: 8px 12px;
149
+ }
150
+ .molgrid-info {
151
+ font-size: 12px;
152
+ }
153
+ .toggle-text {
154
+ font-size: 11px;
155
+ }
156
+ }
157
+
158
+ @media (max-width: 700px) {
159
+ .molgrid-search {
160
+ order: 1;
161
+ flex-basis: 100%;
162
+ min-width: unset;
163
+ }
164
+ .molgrid-info {
165
+ order: 2;
166
+ flex: 1;
167
+ min-width: 0;
168
+ }
169
+ .molgrid-actions {
170
+ order: 3;
171
+ flex-shrink: 0;
172
+ }
173
+ }
174
+
175
+ @media (max-width: 500px) {
176
+ .molgrid-toolbar {
177
+ gap: 6px 10px;
178
+ }
179
+ .molgrid-search {
180
+ flex-direction: column;
181
+ align-items: stretch;
182
+ gap: 8px;
183
+ }
184
+ .toggle-switch {
185
+ justify-content: center;
186
+ }
187
+ }
188
+
189
+ /* Search */
190
+ .molgrid-search {
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 10px;
194
+ flex: 1;
195
+ min-width: 200px;
196
+ }
197
+
198
+ .molgrid-search-input {
199
+ flex: 1;
200
+ padding: 8px 12px;
201
+ border: 1px solid #ced4da;
202
+ border-radius: 4px;
203
+ font-size: 14px;
204
+ outline: none;
205
+ transition: border-color 0.2s, box-shadow 0.2s;
206
+ }
207
+
208
+ .molgrid-search-input:focus {
209
+ border-color: #80bdff;
210
+ box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.15);
211
+ }
212
+
213
+ /* Toggle Switch */
214
+ .toggle-switch {
215
+ display: flex;
216
+ align-items: center;
217
+ }
218
+
219
+ .toggle-label {
220
+ display: flex;
221
+ align-items: center;
222
+ cursor: pointer;
223
+ user-select: none;
224
+ gap: 6px;
225
+ }
226
+
227
+ .toggle-label input[type="checkbox"] {
228
+ position: relative;
229
+ width: 40px;
230
+ height: 20px;
231
+ appearance: none;
232
+ -webkit-appearance: none;
233
+ background: #6c757d;
234
+ border-radius: 10px;
235
+ cursor: pointer;
236
+ transition: background 0.3s;
237
+ margin: 0;
238
+ }
239
+
240
+ .toggle-label input[type="checkbox"]:checked {
241
+ background: #007bff;
242
+ }
243
+
244
+ .toggle-label input[type="checkbox"]::before {
245
+ content: "";
246
+ position: absolute;
247
+ top: 2px;
248
+ left: 2px;
249
+ width: 16px;
250
+ height: 16px;
251
+ background: white;
252
+ border-radius: 50%;
253
+ transition: transform 0.3s;
254
+ }
255
+
256
+ .toggle-label input[type="checkbox"]:checked::before {
257
+ transform: translateX(20px);
258
+ }
259
+
260
+ .toggle-text {
261
+ font-size: 12px;
262
+ color: #6c757d;
263
+ }
264
+
265
+ .toggle-text.active {
266
+ color: #333;
267
+ font-weight: 500;
268
+ }
269
+
270
+ /* Info */
271
+ .molgrid-info {
272
+ color: #6c757d;
273
+ font-size: 13px;
274
+ white-space: nowrap;
275
+ overflow: hidden;
276
+ text-overflow: ellipsis;
277
+ flex-shrink: 1;
278
+ }
279
+
280
+ /* Grid List */
281
+ .molgrid-list {
282
+ display: grid;
283
+ grid-template-columns: repeat(auto-fill, minmax(var(--molgrid-cell-width, 220px), 1fr));
284
+ gap: 12px;
285
+ list-style: none;
286
+ padding: 0;
287
+ margin: 0;
288
+ overflow: visible;
289
+ }
290
+
291
+ /* Cell */
292
+ .molgrid-cell {
293
+ position: relative;
294
+ display: flex;
295
+ flex-direction: column;
296
+ align-items: center;
297
+ padding: 10px;
298
+ background: white;
299
+ border: 1px solid #e9ecef;
300
+ border-radius: 6px;
301
+ transition: border-color 0.2s, box-shadow 0.2s;
302
+ cursor: pointer;
303
+ overflow: hidden;
304
+ box-sizing: border-box;
305
+ }
306
+
307
+ .molgrid-cell:hover {
308
+ border-color: #80bdff;
309
+ box-shadow: 0 2px 8px rgba(0, 123, 255, 0.15);
310
+ }
311
+
312
+ .molgrid-cell.selected {
313
+ border-color: #007bff;
314
+ background: #f0f7ff;
315
+ }
316
+
317
+ /* Checkbox */
318
+ .molgrid-checkbox {
319
+ position: absolute;
320
+ top: 8px;
321
+ left: 8px;
322
+ width: 16px;
323
+ height: 16px;
324
+ cursor: pointer;
325
+ z-index: 10;
326
+ }
327
+
328
+ /* Info Button */
329
+ .molgrid-info-btn {
330
+ position: absolute;
331
+ top: 8px;
332
+ right: 8px;
333
+ width: 18px;
334
+ height: 18px;
335
+ border-radius: 50%;
336
+ background: #e9ecef;
337
+ border: none;
338
+ cursor: pointer;
339
+ display: flex;
340
+ align-items: center;
341
+ justify-content: center;
342
+ font-size: 12px;
343
+ font-weight: 600;
344
+ font-style: italic;
345
+ font-family: Georgia, "Times New Roman", serif;
346
+ color: #6c757d;
347
+ transition: background 0.2s, color 0.2s, transform 0.2s;
348
+ z-index: 10;
349
+ padding: 0;
350
+ line-height: 1;
351
+ }
352
+
353
+ .molgrid-info-btn:hover {
354
+ background: #007bff;
355
+ color: white;
356
+ transform: scale(1.1);
357
+ }
358
+
359
+ /* Info Tooltip */
360
+ .molgrid-info-tooltip {
361
+ display: none;
362
+ position: absolute;
363
+ top: 30px;
364
+ right: 8px;
365
+ min-width: 120px;
366
+ max-width: 220px;
367
+ background: #212529;
368
+ color: white;
369
+ padding: 8px 10px;
370
+ border-radius: 6px;
371
+ font-size: 12px;
372
+ z-index: 100;
373
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
374
+ }
375
+
376
+ .molgrid-info-tooltip::before {
377
+ content: "";
378
+ position: absolute;
379
+ top: -6px;
380
+ right: 12px;
381
+ border-left: 6px solid transparent;
382
+ border-right: 6px solid transparent;
383
+ border-bottom: 6px solid #212529;
384
+ }
385
+
386
+ /* Show on hover OR when pinned */
387
+ .molgrid-info-btn:hover + .molgrid-info-tooltip,
388
+ .molgrid-info-tooltip.pinned {
389
+ display: block;
390
+ }
391
+
392
+ /* Highlight button when tooltip is pinned */
393
+ .molgrid-info-btn.active {
394
+ background: #007bff;
395
+ color: white;
396
+ }
397
+
398
+ .molgrid-info-tooltip-row {
399
+ display: flex;
400
+ margin-bottom: 4px;
401
+ }
402
+
403
+ .molgrid-info-tooltip-row:last-child {
404
+ margin-bottom: 0;
405
+ }
406
+
407
+ .molgrid-info-tooltip-label {
408
+ font-weight: 600;
409
+ margin-right: 6px;
410
+ color: #adb5bd;
411
+ }
412
+
413
+ .molgrid-info-tooltip-value {
414
+ word-break: break-word;
415
+ }
416
+
417
+ /* Image */
418
+ .molgrid-image {
419
+ display: flex;
420
+ justify-content: center;
421
+ align-items: center;
422
+ width: 100%;
423
+ height: var(--molgrid-image-height, 150px);
424
+ overflow: hidden;
425
+ flex-shrink: 0;
426
+ }
427
+
428
+ .molgrid-image svg {
429
+ max-width: 100%;
430
+ max-height: 100%;
431
+ width: auto !important;
432
+ height: auto !important;
433
+ }
434
+
435
+ .molgrid-image img {
436
+ max-width: 100%;
437
+ max-height: 100%;
438
+ width: auto;
439
+ height: auto;
440
+ object-fit: contain;
441
+ }
442
+
443
+ /* Title */
444
+ .molgrid-title {
445
+ margin-top: 8px;
446
+ font-size: 13px;
447
+ font-weight: 500;
448
+ text-align: center;
449
+ word-break: break-word;
450
+ color: #495057;
451
+ }
452
+
453
+ /* Pagination */
454
+ .molgrid-pagination {
455
+ display: flex;
456
+ justify-content: center;
457
+ margin-top: 20px;
458
+ padding: 10px;
459
+ }
460
+
461
+ .molgrid-pagination .pagination {
462
+ display: flex;
463
+ list-style: none;
464
+ padding: 0;
465
+ margin: 0;
466
+ gap: 4px;
467
+ }
468
+
469
+ .molgrid-pagination .pagination li {
470
+ display: inline-block;
471
+ }
472
+
473
+ .molgrid-pagination .pagination li a,
474
+ .molgrid-pagination .pagination li span {
475
+ display: inline-block;
476
+ padding: 6px 12px;
477
+ border: 1px solid #dee2e6;
478
+ border-radius: 4px;
479
+ color: #007bff;
480
+ text-decoration: none;
481
+ cursor: pointer;
482
+ transition: background 0.2s, border-color 0.2s;
483
+ }
484
+
485
+ .molgrid-pagination .pagination li a:hover {
486
+ background: #e9ecef;
487
+ border-color: #dee2e6;
488
+ }
489
+
490
+ .molgrid-pagination .pagination li.active a,
491
+ .molgrid-pagination .pagination li.active span {
492
+ background: #007bff;
493
+ border-color: #007bff;
494
+ color: white;
495
+ }
496
+
497
+ .molgrid-pagination .pagination li.disabled a,
498
+ .molgrid-pagination .pagination li.disabled span {
499
+ color: #6c757d;
500
+ cursor: not-allowed;
501
+ background: #f8f9fa;
502
+ }
503
+
504
+ /* Prev/Next Buttons */
505
+ .molgrid-pagination-nav {
506
+ display: flex;
507
+ justify-content: center;
508
+ align-items: center;
509
+ gap: 10px;
510
+ margin-top: 20px;
511
+ padding: 10px;
512
+ }
513
+
514
+ .molgrid-pagination-nav button {
515
+ width: 100px;
516
+ padding: 6px 12px;
517
+ border: 1px solid #dee2e6;
518
+ border-radius: 4px;
519
+ background: white;
520
+ color: #007bff;
521
+ font-size: 14px;
522
+ cursor: pointer;
523
+ transition: background 0.2s, border-color 0.2s;
524
+ }
525
+
526
+ .molgrid-pagination-nav button:hover:not(:disabled) {
527
+ background: #e9ecef;
528
+ border-color: #dee2e6;
529
+ }
530
+
531
+ .molgrid-pagination-nav button:disabled {
532
+ color: #6c757d;
533
+ cursor: not-allowed;
534
+ background: #f8f9fa;
535
+ }
536
+
537
+ .molgrid-pagination-nav .molgrid-pagination {
538
+ margin-top: 0;
539
+ padding: 0;
540
+ }
541
+
542
+ /* Actions Dropdown */
543
+ .molgrid-actions {
544
+ position: relative;
545
+ overflow: visible;
546
+ }
547
+
548
+ .molgrid-actions-btn {
549
+ padding: 6px 12px;
550
+ border: 1px solid #ced4da;
551
+ border-radius: 4px;
552
+ background: white;
553
+ color: #495057;
554
+ font-size: 16px;
555
+ font-weight: bold;
556
+ cursor: pointer;
557
+ transition: background 0.2s, border-color 0.2s;
558
+ line-height: 1;
559
+ }
560
+
561
+ .molgrid-actions-btn:hover {
562
+ background: #e9ecef;
563
+ border-color: #adb5bd;
564
+ }
565
+
566
+ .molgrid-dropdown {
567
+ display: none;
568
+ position: fixed;
569
+ min-width: 180px;
570
+ background: white;
571
+ border: 1px solid #dee2e6;
572
+ border-radius: 6px;
573
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
574
+ z-index: 1000;
575
+ }
576
+
577
+ .molgrid-dropdown.show {
578
+ display: block;
579
+ }
580
+
581
+ .molgrid-dropdown-item {
582
+ display: block;
583
+ width: 100%;
584
+ padding: 10px 14px;
585
+ border: none;
586
+ background: none;
587
+ text-align: left;
588
+ font-size: 14px;
589
+ color: #212529;
590
+ cursor: pointer;
591
+ transition: background 0.15s;
592
+ }
593
+
594
+ .molgrid-dropdown-item:hover {
595
+ background: #f8f9fa;
596
+ }
597
+
598
+ .molgrid-dropdown-divider {
599
+ height: 1px;
600
+ margin: 4px 0;
601
+ background: #e9ecef;
602
+ }
603
+ '''
604
+
605
+
606
+ class MolGrid:
607
+ """Interactive molecule grid widget for displaying and selecting molecules."""
608
+
609
+ # Class-level selection storage
610
+ _selections: Dict[str, Dict[int, str]] = {}
611
+
612
+ def __init__(
613
+ self,
614
+ mols: Iterable,
615
+ *,
616
+ dataframe=None,
617
+ mol_col: Optional[str] = None,
618
+ title_field: Optional[str] = "Title",
619
+ tooltip_fields: Optional[List[str]] = None,
620
+ n_items_per_page: int = 24,
621
+ width: int = 200,
622
+ height: int = 200,
623
+ atom_label_font_scale: float = 1.5,
624
+ image_format: str = "svg",
625
+ select: bool = True,
626
+ information: bool = True,
627
+ data: Optional[Union[str, List[str]]] = None,
628
+ search_fields: Optional[List[str]] = None,
629
+ name: Optional[str] = None,
630
+ ):
631
+ """Create an interactive molecule grid widget.
632
+
633
+ :param mols: Iterable of OpenEye molecule objects.
634
+ :param dataframe: Optional DataFrame with molecule data.
635
+ :param mol_col: Column name containing molecules (if using DataFrame).
636
+ :param title_field: Molecule field to display as title (None to hide).
637
+ :param tooltip_fields: List of fields for tooltip display.
638
+ :param n_items_per_page: Number of molecules per page.
639
+ :param width: Image width in pixels.
640
+ :param height: Image height in pixels.
641
+ :param atom_label_font_scale: Scale factor for atom labels.
642
+ :param image_format: Image format ("svg" or "png").
643
+ :param select: Enable selection checkboxes.
644
+ :param information: Enable info button with hover tooltip.
645
+ :param data: Column(s) to display in info tooltip. If None, auto-detects
646
+ simple types (string, int, float) from DataFrame.
647
+ :param search_fields: Fields for text search.
648
+ :param name: Grid identifier.
649
+ """
650
+ self._molecules = list(mols)
651
+ self._dataframe = dataframe
652
+ self._mol_col = mol_col
653
+ self.title_field = title_field
654
+ self.tooltip_fields = tooltip_fields or []
655
+ self.n_items_per_page = n_items_per_page
656
+ self.selection_enabled = select
657
+ self.information_enabled = information
658
+ self.name = name
659
+
660
+ # Handle data parameter for info tooltip columns
661
+ if data is not None:
662
+ if isinstance(data, str):
663
+ self.information_fields = [data]
664
+ else:
665
+ self.information_fields = list(data)
666
+ elif dataframe is not None:
667
+ # Auto-detect simple type columns
668
+ self.information_fields = self._auto_detect_info_fields(dataframe, mol_col)
669
+ else:
670
+ self.information_fields = []
671
+
672
+ # Auto-detect search fields from DataFrame if not provided
673
+ if search_fields is None and dataframe is not None:
674
+ self.search_fields = self._auto_detect_search_fields(dataframe, mol_col)
675
+ else:
676
+ self.search_fields = search_fields
677
+
678
+ # Rendering settings
679
+ self.width = width
680
+ self.height = height
681
+ self.image_format = image_format
682
+ self.atom_label_font_scale = atom_label_font_scale
683
+
684
+ # Generate grid name
685
+ if self.name is None:
686
+ self.name = f"molgrid-{uuid.uuid4().hex[:8]}"
687
+
688
+ # Initialize selection storage
689
+ MolGrid._selections[self.name] = {}
690
+
691
+ # Create widget
692
+ self.widget = MolGridWidget(grid_id=self.name)
693
+
694
+ # Observe selection changes
695
+ self.widget.observe(self._on_selection_change, names=["selection"])
696
+
697
+ # Observe SMARTS query changes
698
+ self.widget.observe(self._on_smarts_query, names=["smarts_query"])
699
+
700
+ # Display widget immediately (critical for model availability)
701
+ # In Jupyter: display widget here so model is available when iframe loads
702
+ # In Marimo: widget is returned from display() method instead
703
+ if not _is_marimo():
704
+ display(self.widget)
705
+
706
+ def _auto_detect_search_fields(self, dataframe, mol_col: Optional[str]) -> List[str]:
707
+ """Auto-detect searchable text columns from DataFrame.
708
+
709
+ :param dataframe: DataFrame to inspect.
710
+ :param mol_col: Molecule column name to exclude.
711
+ :returns: List of searchable column names.
712
+ """
713
+ search_fields = []
714
+ for col in dataframe.columns:
715
+ # Skip the molecule column
716
+ if col == mol_col:
717
+ continue
718
+
719
+ dtype = dataframe[col].dtype
720
+ dtype_str = str(dtype).lower()
721
+
722
+ # Skip molecule dtypes (oepandas MoleculeDtype)
723
+ if "molecule" in dtype_str:
724
+ continue
725
+
726
+ # Skip numeric dtypes
727
+ if any(t in dtype_str for t in ["int", "float", "double", "decimal"]):
728
+ continue
729
+
730
+ # Include string/object columns that likely contain text
731
+ if "object" in dtype_str or "string" in dtype_str or "str" in dtype_str:
732
+ search_fields.append(col)
733
+ continue
734
+
735
+ # Also check if column contains string values (fallback for edge cases)
736
+ try:
737
+ first_valid = dataframe[col].dropna().iloc[0] if len(dataframe[col].dropna()) > 0 else None
738
+ if first_valid is not None and isinstance(first_valid, str):
739
+ search_fields.append(col)
740
+ except (IndexError, KeyError):
741
+ pass
742
+
743
+ return search_fields
744
+
745
+ def _auto_detect_info_fields(self, dataframe, mol_col: Optional[str]) -> List[str]:
746
+ """Auto-detect simple type columns from DataFrame for info tooltip.
747
+
748
+ Includes string, int, and float columns (excludes molecule columns).
749
+
750
+ :param dataframe: DataFrame to inspect.
751
+ :param mol_col: Molecule column name to exclude.
752
+ :returns: List of column names with simple types.
753
+ """
754
+ info_fields = []
755
+ for col in dataframe.columns:
756
+ # Skip the molecule column
757
+ if col == mol_col:
758
+ continue
759
+
760
+ dtype = dataframe[col].dtype
761
+ dtype_str = str(dtype).lower()
762
+
763
+ # Skip molecule dtypes (oepandas MoleculeDtype)
764
+ if "molecule" in dtype_str:
765
+ continue
766
+
767
+ # Include numeric types (int, float)
768
+ if any(t in dtype_str for t in ["int", "float", "double", "decimal"]):
769
+ info_fields.append(col)
770
+ continue
771
+
772
+ # Include string/object columns
773
+ if "object" in dtype_str or "string" in dtype_str or "str" in dtype_str:
774
+ info_fields.append(col)
775
+ continue
776
+
777
+ # Include category dtype
778
+ if "category" in dtype_str:
779
+ info_fields.append(col)
780
+ continue
781
+
782
+ return info_fields
783
+
784
+ def _on_selection_change(self, change):
785
+ """Handle selection change from widget.
786
+
787
+ :param change: Traitlet change dict with 'new' key.
788
+ """
789
+ try:
790
+ selection = json.loads(change["new"])
791
+ MolGrid._selections[self.name] = {int(k): v for k, v in selection.items()}
792
+ except (json.JSONDecodeError, ValueError, TypeError):
793
+ pass
794
+
795
+ def _on_smarts_query(self, change):
796
+ """Handle SMARTS query from widget.
797
+
798
+ Performs substructure search and sends matching indices back.
799
+
800
+ :param change: Traitlet change dict with 'new' key.
801
+ """
802
+ query = change["new"]
803
+ if not query:
804
+ # Empty query - return all indices
805
+ matches = list(range(len(self._molecules)))
806
+ else:
807
+ matches = self._search_smarts(query)
808
+
809
+ # Send results back to widget
810
+ self.widget.smarts_matches = json.dumps(matches)
811
+
812
+ def _search_smarts(self, smarts_pattern: str) -> List[int]:
813
+ """Search molecules by SMARTS pattern.
814
+
815
+ :param smarts_pattern: SMARTS pattern string.
816
+ :returns: List of indices of matching molecules.
817
+ """
818
+ matches = []
819
+ try:
820
+ ss = oechem.OESubSearch(smarts_pattern)
821
+ if not ss.IsValid():
822
+ return matches
823
+
824
+ for idx, mol in enumerate(self._molecules):
825
+ oechem.OEPrepareSearch(mol, ss)
826
+ if ss.SingleMatch(mol):
827
+ matches.append(idx)
828
+ except Exception:
829
+ # Invalid SMARTS or other error - return empty
830
+ pass
831
+
832
+ return matches
833
+
834
+ def _get_field_value(self, idx: int, mol, field: str):
835
+ """Get a field value from DataFrame column or molecule property.
836
+
837
+ :param idx: Row index in the dataframe.
838
+ :param mol: OpenEye molecule object.
839
+ :param field: Field name to retrieve.
840
+ :returns: Field value or None.
841
+ """
842
+ # Try DataFrame column first
843
+ if self._dataframe is not None and field in self._dataframe.columns:
844
+ return self._dataframe.iloc[idx][field]
845
+
846
+ # Fall back to molecule properties
847
+ if field == "Title":
848
+ return mol.GetTitle() or None
849
+ else:
850
+ return oechem.OEGetSDData(mol, field) or None
851
+
852
+ def _prepare_data(self) -> List[dict]:
853
+ """Prepare molecule data for template rendering.
854
+
855
+ :returns: List of dicts with molecule data for each item.
856
+ """
857
+ data = []
858
+ ctx = CNotebookContext(
859
+ width=self.width,
860
+ height=self.height,
861
+ image_format=self.image_format,
862
+ atom_label_font_scale=self.atom_label_font_scale,
863
+ scope="local",
864
+ )
865
+
866
+ for idx, mol in enumerate(self._molecules):
867
+ item = {
868
+ "index": idx,
869
+ "title": None,
870
+ "mol_title": mol.GetTitle() if mol.IsValid() else None,
871
+ "tooltip": {},
872
+ "smiles": oechem.OEMolToSmiles(mol) if mol.IsValid() else "",
873
+ "img": oemol_to_html(mol, ctx=ctx),
874
+ }
875
+
876
+ # Extract title
877
+ if self.title_field:
878
+ item["title"] = self._get_field_value(idx, mol, self.title_field)
879
+
880
+ # Extract tooltip fields
881
+ for field in self.tooltip_fields:
882
+ item["tooltip"][field] = self._get_field_value(idx, mol, field)
883
+
884
+ # Extract search fields
885
+ item["search_fields"] = {}
886
+ if self.search_fields:
887
+ for field in self.search_fields:
888
+ item["search_fields"][field] = self._get_field_value(idx, mol, field)
889
+
890
+ # Extract information fields for tooltip
891
+ item["info_fields"] = {}
892
+ if self.information_fields:
893
+ for field in self.information_fields:
894
+ item["info_fields"][field] = self._get_field_value(idx, mol, field)
895
+
896
+ data.append(item)
897
+
898
+ return data
899
+
900
+ def _prepare_export_data(self) -> List[dict]:
901
+ """Prepare molecule data for CSV/SMILES export.
902
+
903
+ :returns: List of dicts with all exportable data for each molecule.
904
+ """
905
+ export_data = []
906
+
907
+ # Determine columns to export
908
+ if self._dataframe is not None:
909
+ columns = [c for c in self._dataframe.columns if c != self._mol_col]
910
+ else:
911
+ columns = []
912
+
913
+ for idx, mol in enumerate(self._molecules):
914
+ row = {
915
+ "index": idx,
916
+ "smiles": oechem.OEMolToSmiles(mol) if mol.IsValid() else "",
917
+ }
918
+
919
+ # Add DataFrame columns
920
+ if self._dataframe is not None:
921
+ for col in columns:
922
+ val = self._dataframe.iloc[idx][col]
923
+ # Convert to string for JSON serialization
924
+ if val is None or (hasattr(val, '__len__') and len(str(val)) == 0):
925
+ row[col] = ""
926
+ else:
927
+ row[col] = str(val)
928
+
929
+ export_data.append(row)
930
+
931
+ return export_data
932
+
933
+ def to_html(self) -> str:
934
+ """Generate HTML representation of the grid.
935
+
936
+ :returns: Complete HTML document as string.
937
+ """
938
+ items = self._prepare_data()
939
+ export_data = self._prepare_export_data()
940
+ grid_id = self.name
941
+ items_per_page = self.n_items_per_page
942
+ total_items = len(items)
943
+
944
+ # Prepare export data as JSON for JavaScript
945
+ export_data_js = json.dumps(export_data)
946
+ # Get column names for CSV header
947
+ export_columns = ["smiles"]
948
+ if self._dataframe is not None:
949
+ export_columns.extend([c for c in self._dataframe.columns if c != self._mol_col])
950
+ export_columns_js = json.dumps(export_columns)
951
+
952
+ # Build item HTML - IMPORTANT: data-smiles on cell element for working selection
953
+ items_html = ""
954
+ for item in items:
955
+ tooltip_str = ""
956
+ if item["tooltip"]:
957
+ tooltip_parts = [f"{k}: {v}" for k, v in item["tooltip"].items() if v]
958
+ tooltip_str = "
".join(tooltip_parts)
959
+
960
+ # Visible title display
961
+ title_display_html = ""
962
+ if item["title"]:
963
+ title_display_html = f'<div class="molgrid-title">{escape(str(item["title"]))}</div>'
964
+
965
+ # Hidden title span for List.js search (always present for consistent indexing)
966
+ title_value = escape(str(item["title"])) if item["title"] else ""
967
+
968
+ checkbox_html = ""
969
+ if self.selection_enabled:
970
+ checkbox_html = f'<input type="checkbox" class="molgrid-checkbox" data-index="{item["index"]}">'
971
+
972
+ # Info button with tooltip
973
+ info_html = ""
974
+ if self.information_enabled:
975
+ # Build tooltip content: Index always, Title if available, then data fields
976
+ info_rows = f'<div class="molgrid-info-tooltip-row"><span class="molgrid-info-tooltip-label">Index:</span><span class="molgrid-info-tooltip-value">{item["index"]}</span></div>'
977
+ if item.get("mol_title"):
978
+ info_rows += f'<div class="molgrid-info-tooltip-row"><span class="molgrid-info-tooltip-label">Title:</span><span class="molgrid-info-tooltip-value">{escape(str(item["mol_title"]))}</span></div>'
979
+ # Add data fields from info_fields
980
+ for field, value in item.get("info_fields", {}).items():
981
+ if value is not None:
982
+ display_value = escape(str(value))
983
+ info_rows += f'<div class="molgrid-info-tooltip-row"><span class="molgrid-info-tooltip-label">{escape(field)}:</span><span class="molgrid-info-tooltip-value">{display_value}</span></div>'
984
+ info_html = f'''<button class="molgrid-info-btn" type="button">i</button>
985
+ <div class="molgrid-info-tooltip">{info_rows}</div>'''
986
+
987
+ tooltip_attr = f'title="{tooltip_str}"' if tooltip_str else ""
988
+
989
+ # Build hidden spans for search fields
990
+ search_fields_html = ""
991
+ for field, value in item["search_fields"].items():
992
+ safe_value = escape(str(value)) if value else ""
993
+ search_fields_html += f'<span class="{escape(field)}" style="display:none;">{safe_value}</span>\n '
994
+
995
+ # Keep data-smiles on cell element (critical for selection to work)
996
+ # Add hidden spans for List.js valueNames (index, smiles, title, search fields)
997
+ items_html += f'''
998
+ <li class="molgrid-cell" data-index="{item["index"]}" data-smiles="{escape(item["smiles"])}" {tooltip_attr}>
999
+ {checkbox_html}
1000
+ {info_html}
1001
+ <div class="molgrid-image">{item["img"]}</div>
1002
+ {title_display_html}
1003
+ <span class="title" style="display:none;">{title_value}</span>
1004
+ <span class="index" style="display:none;">{item["index"]}</span>
1005
+ <span class="smiles" style="display:none;">{escape(item["smiles"])}</span>
1006
+ {search_fields_html}
1007
+ </li>
1008
+ '''
1009
+
1010
+ # Prepare search fields for JavaScript
1011
+ search_fields_js = json.dumps(self.search_fields or [])
1012
+
1013
+ # JavaScript for selection sync with List.js pagination
1014
+ js = f'''
1015
+ (function() {{
1016
+ var gridId = "{grid_id}";
1017
+ var itemsPerPage = {items_per_page};
1018
+ var searchFields = {search_fields_js};
1019
+ var exportData = {export_data_js};
1020
+ var exportColumns = {export_columns_js};
1021
+ var container = document.getElementById(gridId);
1022
+ var selectedIndices = new Set();
1023
+ var searchMode = 'properties'; // 'properties' or 'smarts'
1024
+
1025
+ // Initialize List.js with pagination
1026
+ var options = {{
1027
+ valueNames: ['title', 'index', 'smiles'].concat(searchFields),
1028
+ page: itemsPerPage,
1029
+ pagination: {{
1030
+ innerWindow: 1,
1031
+ outerWindow: 1,
1032
+ left: 1,
1033
+ right: 1,
1034
+ paginationClass: 'pagination',
1035
+ item: '<li><a class="page" href="javascript:void(0)"></a></li>'
1036
+ }}
1037
+ }};
1038
+
1039
+ var molgridList = new List(gridId, options);
1040
+
1041
+ // Prev/Next buttons
1042
+ var prevBtn = container.querySelector('.molgrid-prev');
1043
+ var nextBtn = container.querySelector('.molgrid-next');
1044
+
1045
+ // Update showing info text and prev/next button states
1046
+ function updateShowingInfo() {{
1047
+ var totalItems = molgridList.matchingItems.length;
1048
+ var currentPage = molgridList.i;
1049
+ var perPage = molgridList.page;
1050
+
1051
+ var start = totalItems > 0 ? currentPage : 0;
1052
+ var end = Math.min(currentPage + perPage - 1, totalItems);
1053
+
1054
+ var showingStart = container.querySelector('.showing-start');
1055
+ var showingEnd = container.querySelector('.showing-end');
1056
+ var showingTotal = container.querySelector('.showing-total');
1057
+
1058
+ if (showingStart) showingStart.textContent = start;
1059
+ if (showingEnd) showingEnd.textContent = end;
1060
+ if (showingTotal) showingTotal.textContent = totalItems;
1061
+
1062
+ // Update prev/next button states
1063
+ var totalPages = Math.ceil(totalItems / perPage);
1064
+ var currentPageNum = Math.ceil(currentPage / perPage);
1065
+
1066
+ prevBtn.disabled = currentPageNum <= 1;
1067
+ nextBtn.disabled = currentPageNum >= totalPages || totalPages <= 1;
1068
+ }}
1069
+
1070
+ // Prev button click handler
1071
+ prevBtn.addEventListener('click', function() {{
1072
+ var totalItems = molgridList.matchingItems.length;
1073
+ var currentPage = molgridList.i;
1074
+ var perPage = molgridList.page;
1075
+
1076
+ if (currentPage > 1) {{
1077
+ var newStart = currentPage - perPage;
1078
+ if (newStart < 1) newStart = 1;
1079
+ molgridList.show(newStart, perPage);
1080
+ }}
1081
+ }});
1082
+
1083
+ // Next button click handler
1084
+ nextBtn.addEventListener('click', function() {{
1085
+ var totalItems = molgridList.matchingItems.length;
1086
+ var currentPage = molgridList.i;
1087
+ var perPage = molgridList.page;
1088
+ var totalPages = Math.ceil(totalItems / perPage);
1089
+ var currentPageNum = Math.ceil(currentPage / perPage);
1090
+
1091
+ if (currentPageNum < totalPages) {{
1092
+ var newStart = currentPage + perPage;
1093
+ molgridList.show(newStart, perPage);
1094
+ }}
1095
+ }});
1096
+
1097
+ // Search functionality
1098
+ var searchInput = container.querySelector('.molgrid-search-input');
1099
+ var searchModeToggle = container.querySelector('.search-mode-toggle');
1100
+ var toggleTexts = container.querySelectorAll('.toggle-text');
1101
+ var smartsMatchIndices = null; // Store SMARTS match results
1102
+
1103
+ // Debounce function for search
1104
+ function debounce(func, wait) {{
1105
+ var timeout;
1106
+ return function() {{
1107
+ var context = this;
1108
+ var args = arguments;
1109
+ clearTimeout(timeout);
1110
+ timeout = setTimeout(function() {{
1111
+ func.apply(context, args);
1112
+ }}, wait);
1113
+ }};
1114
+ }}
1115
+
1116
+ // Apply SMARTS filter based on matching indices
1117
+ function applySmartsFilter(matchIndices) {{
1118
+ smartsMatchIndices = new Set(matchIndices);
1119
+ molgridList.filter(function(item) {{
1120
+ var idx = parseInt(item.values().index, 10);
1121
+ return smartsMatchIndices.has(idx);
1122
+ }});
1123
+ molgridList.i = 1; // Reset to first page
1124
+ updateShowingInfo();
1125
+ }}
1126
+
1127
+ // Clear SMARTS filter
1128
+ function clearSmartsFilter() {{
1129
+ smartsMatchIndices = null;
1130
+ molgridList.filter(); // Clear filter
1131
+ molgridList.i = 1;
1132
+ updateShowingInfo();
1133
+ }}
1134
+
1135
+ // Send SMARTS query to Python
1136
+ function sendSmartsQuery(query) {{
1137
+ window.parent.postMessage({{
1138
+ type: "MOLGRID_SMARTS_QUERY",
1139
+ gridId: gridId,
1140
+ query: query
1141
+ }}, "*");
1142
+ }}
1143
+
1144
+ // Listen for SMARTS results from Python
1145
+ window.addEventListener("message", function(event) {{
1146
+ if (event.data && event.data.gridId === gridId && event.data.type === "MOLGRID_SMARTS_RESULTS") {{
1147
+ var matches = JSON.parse(event.data.matches);
1148
+ applySmartsFilter(matches);
1149
+ }}
1150
+ }});
1151
+
1152
+ // Perform search based on current mode
1153
+ function performSearch(query) {{
1154
+ if (!query) {{
1155
+ molgridList.search(); // Clear text search
1156
+ clearSmartsFilter(); // Clear SMARTS filter
1157
+ }} else if (searchMode === 'smarts') {{
1158
+ // SMARTS mode - send to Python for substructure search
1159
+ molgridList.search(); // Clear any text search
1160
+ sendSmartsQuery(query);
1161
+ }} else {{
1162
+ // Properties mode - search title and configured fields
1163
+ clearSmartsFilter(); // Clear any SMARTS filter
1164
+ var fieldsToSearch = ['title'].concat(searchFields);
1165
+ molgridList.search(query, fieldsToSearch);
1166
+ molgridList.i = 1; // Reset to first page
1167
+ updateShowingInfo();
1168
+ }}
1169
+ }}
1170
+
1171
+ // Debounced search handler
1172
+ var debouncedSearch = debounce(function(e) {{
1173
+ performSearch(e.target.value);
1174
+ }}, 300);
1175
+
1176
+ searchInput.addEventListener('input', debouncedSearch);
1177
+
1178
+ // Search mode toggle handler
1179
+ searchModeToggle.addEventListener('change', function() {{
1180
+ searchMode = this.checked ? 'smarts' : 'properties';
1181
+
1182
+ // Update toggle text styling
1183
+ toggleTexts[0].classList.toggle('active', !this.checked);
1184
+ toggleTexts[1].classList.toggle('active', this.checked);
1185
+
1186
+ // Re-trigger search with current query
1187
+ if (searchInput.value) {{
1188
+ performSearch(searchInput.value);
1189
+ }} else {{
1190
+ // Clear any existing filter when switching modes with empty query
1191
+ clearSmartsFilter();
1192
+ }}
1193
+ }});
1194
+
1195
+ // Update checkboxes based on selection state (after page change)
1196
+ function updateCheckboxes() {{
1197
+ var checkboxes = container.querySelectorAll('.molgrid-checkbox');
1198
+ checkboxes.forEach(function(checkbox) {{
1199
+ var index = parseInt(checkbox.getAttribute('data-index'), 10);
1200
+ checkbox.checked = selectedIndices.has(index);
1201
+ var cell = checkbox.closest('.molgrid-cell');
1202
+ if (cell) {{
1203
+ cell.classList.toggle('selected', checkbox.checked);
1204
+ }}
1205
+ }});
1206
+ }}
1207
+
1208
+ function syncSelection() {{
1209
+ var selection = {{}};
1210
+ selectedIndices.forEach(function(idx) {{
1211
+ var cell = container.querySelector('.molgrid-cell[data-index="' + idx + '"]');
1212
+ var smiles = cell ? cell.getAttribute("data-smiles") : '';
1213
+ selection[idx] = smiles;
1214
+ }});
1215
+
1216
+ var globalName = "_MOLGRID_" + gridId;
1217
+ var model = window.parent[globalName];
1218
+
1219
+ if (model) {{
1220
+ model.set("selection", JSON.stringify(selection));
1221
+ model.save_changes();
1222
+ }} else {{
1223
+ window.parent.postMessage({{
1224
+ type: "MOLGRID_SELECTION",
1225
+ gridId: gridId,
1226
+ selection: selection
1227
+ }}, "*");
1228
+ }}
1229
+ }}
1230
+
1231
+ // Listen for List.js updates (page changes, filtering)
1232
+ molgridList.on('updated', function() {{
1233
+ updateShowingInfo();
1234
+ updateCheckboxes();
1235
+ }});
1236
+
1237
+ container.addEventListener("change", function(e) {{
1238
+ if (e.target.classList.contains("molgrid-checkbox")) {{
1239
+ var index = parseInt(e.target.getAttribute("data-index"), 10);
1240
+ var cell = e.target.closest(".molgrid-cell");
1241
+
1242
+ if (e.target.checked) {{
1243
+ selectedIndices.add(index);
1244
+ cell.classList.add("selected");
1245
+ }} else {{
1246
+ selectedIndices.delete(index);
1247
+ cell.classList.remove("selected");
1248
+ }}
1249
+
1250
+ syncSelection();
1251
+ }}
1252
+ }});
1253
+
1254
+ container.addEventListener("click", function(e) {{
1255
+ var cell = e.target.closest(".molgrid-cell");
1256
+ // Don't toggle selection when clicking checkbox, info button, or tooltip
1257
+ var isCheckbox = e.target.classList.contains("molgrid-checkbox");
1258
+ var isInfoBtn = e.target.classList.contains("molgrid-info-btn");
1259
+ var isInfoTooltip = e.target.closest(".molgrid-info-tooltip");
1260
+ if (cell && !isCheckbox && !isInfoBtn && !isInfoTooltip) {{
1261
+ var checkbox = cell.querySelector(".molgrid-checkbox");
1262
+ if (checkbox) {{
1263
+ checkbox.checked = !checkbox.checked;
1264
+ checkbox.dispatchEvent(new Event("change", {{ bubbles: true }}));
1265
+ }}
1266
+ }}
1267
+ }});
1268
+
1269
+ // Prevent default on pagination links (fixes Marimo navigation issue)
1270
+ container.querySelector('.molgrid-pagination').addEventListener('click', function(e) {{
1271
+ if (e.target.tagName === 'A') {{
1272
+ e.preventDefault();
1273
+ }}
1274
+ }});
1275
+
1276
+ // Send height to parent for proper iframe sizing
1277
+ function sendHeight() {{
1278
+ var height = document.body.scrollHeight;
1279
+ window.parent.postMessage({{
1280
+ type: "MOLGRID_HEIGHT",
1281
+ gridId: gridId,
1282
+ height: height
1283
+ }}, "*");
1284
+ }}
1285
+
1286
+ // Initial update
1287
+ updateShowingInfo();
1288
+
1289
+ // Send initial height after a short delay for rendering
1290
+ setTimeout(sendHeight, 100);
1291
+
1292
+ // Also send height after List.js updates
1293
+ molgridList.on('updated', function() {{
1294
+ setTimeout(sendHeight, 50);
1295
+ }});
1296
+
1297
+ // ========================================
1298
+ // Info Button Click-to-Pin
1299
+ // ========================================
1300
+
1301
+ // Handle info button clicks to pin/unpin tooltips
1302
+ container.addEventListener('click', function(e) {{
1303
+ if (e.target.classList.contains('molgrid-info-btn')) {{
1304
+ e.stopPropagation();
1305
+ var btn = e.target;
1306
+ var tooltip = btn.nextElementSibling;
1307
+ if (tooltip && tooltip.classList.contains('molgrid-info-tooltip')) {{
1308
+ var isPinned = tooltip.classList.contains('pinned');
1309
+ if (isPinned) {{
1310
+ // Unpin this tooltip
1311
+ tooltip.classList.remove('pinned');
1312
+ btn.classList.remove('active');
1313
+ }} else {{
1314
+ // Pin this tooltip (don't unpin others - allow comparison)
1315
+ tooltip.classList.add('pinned');
1316
+ btn.classList.add('active');
1317
+ }}
1318
+ }}
1319
+ }}
1320
+ }});
1321
+
1322
+ // ========================================
1323
+ // Actions Dropdown
1324
+ // ========================================
1325
+
1326
+ var actionsBtn = container.querySelector('.molgrid-actions-btn');
1327
+ var dropdown = container.querySelector('.molgrid-dropdown');
1328
+
1329
+ // Position and toggle dropdown
1330
+ function positionDropdown() {{
1331
+ var rect = actionsBtn.getBoundingClientRect();
1332
+ var dropdownHeight = dropdown.offsetHeight || 250;
1333
+ var viewportHeight = window.innerHeight;
1334
+
1335
+ // Check if there's room below the button
1336
+ var spaceBelow = viewportHeight - rect.bottom;
1337
+ var spaceAbove = rect.top;
1338
+
1339
+ if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {{
1340
+ // Position below
1341
+ dropdown.style.top = rect.bottom + 4 + 'px';
1342
+ dropdown.style.bottom = 'auto';
1343
+ }} else {{
1344
+ // Position above
1345
+ dropdown.style.bottom = (viewportHeight - rect.top + 4) + 'px';
1346
+ dropdown.style.top = 'auto';
1347
+ }}
1348
+ // Always align to right edge of viewport with 10px margin
1349
+ dropdown.style.right = '10px';
1350
+ dropdown.style.left = 'auto';
1351
+ }}
1352
+
1353
+ // Toggle dropdown
1354
+ actionsBtn.addEventListener('click', function(e) {{
1355
+ e.stopPropagation();
1356
+ var isShowing = dropdown.classList.contains('show');
1357
+ if (!isShowing) {{
1358
+ positionDropdown();
1359
+ }}
1360
+ dropdown.classList.toggle('show');
1361
+ }});
1362
+
1363
+ // Reposition on scroll/resize
1364
+ window.addEventListener('scroll', function() {{
1365
+ if (dropdown.classList.contains('show')) {{
1366
+ positionDropdown();
1367
+ }}
1368
+ }});
1369
+
1370
+ // Close dropdown when clicking outside
1371
+ document.addEventListener('click', function(e) {{
1372
+ if (!dropdown.contains(e.target) && e.target !== actionsBtn) {{
1373
+ dropdown.classList.remove('show');
1374
+ }}
1375
+ }});
1376
+
1377
+ // Get all item indices (respecting current filter)
1378
+ function getAllIndices() {{
1379
+ return molgridList.items.map(function(item) {{
1380
+ return parseInt(item.values().index, 10);
1381
+ }});
1382
+ }}
1383
+
1384
+ // Get matching item indices (respecting current filter/search)
1385
+ function getMatchingIndices() {{
1386
+ return molgridList.matchingItems.map(function(item) {{
1387
+ return parseInt(item.values().index, 10);
1388
+ }});
1389
+ }}
1390
+
1391
+ // Select All action
1392
+ function selectAll() {{
1393
+ var indices = getMatchingIndices();
1394
+ indices.forEach(function(idx) {{
1395
+ selectedIndices.add(idx);
1396
+ }});
1397
+ updateCheckboxes();
1398
+ syncSelection();
1399
+ }}
1400
+
1401
+ // Clear Selection action
1402
+ function clearSelection() {{
1403
+ selectedIndices.clear();
1404
+ updateCheckboxes();
1405
+ syncSelection();
1406
+ }}
1407
+
1408
+ // Invert Selection action
1409
+ function invertSelection() {{
1410
+ var indices = getMatchingIndices();
1411
+ indices.forEach(function(idx) {{
1412
+ if (selectedIndices.has(idx)) {{
1413
+ selectedIndices.delete(idx);
1414
+ }} else {{
1415
+ selectedIndices.add(idx);
1416
+ }}
1417
+ }});
1418
+ updateCheckboxes();
1419
+ syncSelection();
1420
+ }}
1421
+
1422
+ // CSV escape helper
1423
+ function csvEscape(val) {{
1424
+ if (val === null || val === undefined) return '';
1425
+ var str = String(val);
1426
+ if (str.indexOf(',') !== -1 || str.indexOf('"') !== -1 || str.indexOf('\\n') !== -1) {{
1427
+ return '"' + str.replace(/"/g, '""') + '"';
1428
+ }}
1429
+ return str;
1430
+ }}
1431
+
1432
+ // Get data for export (selected or all)
1433
+ function getExportRows() {{
1434
+ var indices = selectedIndices.size > 0
1435
+ ? Array.from(selectedIndices).sort(function(a, b) {{ return a - b; }})
1436
+ : getMatchingIndices();
1437
+ return indices.map(function(idx) {{
1438
+ return exportData[idx];
1439
+ }});
1440
+ }}
1441
+
1442
+ // Generate CSV content
1443
+ function generateCSV() {{
1444
+ var rows = getExportRows();
1445
+ var lines = [];
1446
+ // Header
1447
+ lines.push(exportColumns.map(csvEscape).join(','));
1448
+ // Data rows
1449
+ rows.forEach(function(row) {{
1450
+ var values = exportColumns.map(function(col) {{
1451
+ return csvEscape(row[col] || '');
1452
+ }});
1453
+ lines.push(values.join(','));
1454
+ }});
1455
+ return lines.join('\\n');
1456
+ }}
1457
+
1458
+ // Generate SMILES content
1459
+ function generateSMILES() {{
1460
+ var rows = getExportRows();
1461
+ return rows.map(function(row) {{
1462
+ return row.smiles || '';
1463
+ }}).filter(function(s) {{ return s; }}).join('\\n');
1464
+ }}
1465
+
1466
+ // Copy to Clipboard action
1467
+ function copyToClipboard() {{
1468
+ var csv = generateCSV();
1469
+ navigator.clipboard.writeText(csv).then(function() {{
1470
+ // Optional: show feedback
1471
+ }}).catch(function(err) {{
1472
+ console.error('Failed to copy:', err);
1473
+ }});
1474
+ }}
1475
+
1476
+ // Save file helper
1477
+ function saveFile(content, filename, mimeType) {{
1478
+ var blob = new Blob([content], {{ type: mimeType }});
1479
+ var url = URL.createObjectURL(blob);
1480
+ var a = document.createElement('a');
1481
+ a.href = url;
1482
+ a.download = filename;
1483
+ document.body.appendChild(a);
1484
+ a.click();
1485
+ document.body.removeChild(a);
1486
+ URL.revokeObjectURL(url);
1487
+ }}
1488
+
1489
+ // Save to SMILES action
1490
+ function saveToSMILES() {{
1491
+ var content = generateSMILES();
1492
+ saveFile(content, 'molgrid.smi', 'chemical/x-daylight-smiles');
1493
+ }}
1494
+
1495
+ // Save to CSV action
1496
+ function saveToCSV() {{
1497
+ var content = generateCSV();
1498
+ saveFile(content, 'molgrid.csv', 'text/csv');
1499
+ }}
1500
+
1501
+ // Handle dropdown item clicks
1502
+ dropdown.addEventListener('click', function(e) {{
1503
+ var item = e.target.closest('.molgrid-dropdown-item');
1504
+ if (!item) return;
1505
+
1506
+ var action = item.dataset.action;
1507
+ dropdown.classList.remove('show');
1508
+
1509
+ switch (action) {{
1510
+ case 'select-all':
1511
+ selectAll();
1512
+ break;
1513
+ case 'clear-selection':
1514
+ clearSelection();
1515
+ break;
1516
+ case 'invert-selection':
1517
+ invertSelection();
1518
+ break;
1519
+ case 'copy-clipboard':
1520
+ copyToClipboard();
1521
+ break;
1522
+ case 'save-smiles':
1523
+ saveToSMILES();
1524
+ break;
1525
+ case 'save-csv':
1526
+ saveToCSV();
1527
+ break;
1528
+ }}
1529
+ }});
1530
+ }})();
1531
+ '''
1532
+
1533
+ return f'''<!DOCTYPE html>
1534
+ <html lang="en">
1535
+ <head>
1536
+ <meta charset="UTF-8">
1537
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1538
+ <title>MolGrid</title>
1539
+ <script>{_LIST_JS}</script>
1540
+ <style>
1541
+ :root {{
1542
+ --molgrid-image-width: {self.width}px;
1543
+ --molgrid-image-height: {self.height}px;
1544
+ --molgrid-cell-width: {self.width + 24}px;
1545
+ }}
1546
+ {_CSS}
1547
+ </style>
1548
+ </head>
1549
+ <body>
1550
+ <div id="{grid_id}" class="molgrid-container">
1551
+ <div class="molgrid-toolbar">
1552
+ <div class="molgrid-search">
1553
+ <input type="text" class="molgrid-search-input" placeholder="Search...">
1554
+ <div class="toggle-switch">
1555
+ <label class="toggle-label">
1556
+ <span class="toggle-text active">Properties</span>
1557
+ <input type="checkbox" class="search-mode-toggle">
1558
+ <span class="toggle-text">SMARTS</span>
1559
+ </label>
1560
+ </div>
1561
+ </div>
1562
+ <div class="molgrid-info">
1563
+ Showing <span class="showing-start">1</span>-<span class="showing-end">{min(items_per_page, total_items)}</span> of <span class="showing-total">{total_items}</span> molecules
1564
+ </div>
1565
+ <div class="molgrid-actions">
1566
+ <button class="molgrid-actions-btn" title="Actions">&#8943;</button>
1567
+ <div class="molgrid-dropdown">
1568
+ <button class="molgrid-dropdown-item" data-action="select-all">Select All</button>
1569
+ <button class="molgrid-dropdown-item" data-action="clear-selection">Clear Selection</button>
1570
+ <button class="molgrid-dropdown-item" data-action="invert-selection">Invert Selection</button>
1571
+ <div class="molgrid-dropdown-divider"></div>
1572
+ <button class="molgrid-dropdown-item" data-action="copy-clipboard">Copy to Clipboard</button>
1573
+ <button class="molgrid-dropdown-item" data-action="save-smiles">Save to SMILES</button>
1574
+ <button class="molgrid-dropdown-item" data-action="save-csv">Save to CSV</button>
1575
+ </div>
1576
+ </div>
1577
+ </div>
1578
+ <ul class="molgrid-list list">
1579
+ {items_html}
1580
+ </ul>
1581
+ <div class="molgrid-pagination-nav">
1582
+ <button class="molgrid-prev" disabled>&laquo; Previous</button>
1583
+ <div class="molgrid-pagination">
1584
+ <ul class="pagination"></ul>
1585
+ </div>
1586
+ <button class="molgrid-next">Next &raquo;</button>
1587
+ </div>
1588
+ </div>
1589
+ <script>
1590
+ {js}
1591
+ </script>
1592
+ </body>
1593
+ </html>'''
1594
+
1595
+ def get_selection(self) -> List:
1596
+ """Get list of selected molecules.
1597
+
1598
+ :returns: List of selected OEMol objects.
1599
+ """
1600
+ selection = MolGrid._selections.get(self.name, {})
1601
+ return [self._molecules[idx] for idx in sorted(selection.keys())]
1602
+
1603
+ def get_selection_indices(self) -> List[int]:
1604
+ """Get indices of selected molecules.
1605
+
1606
+ :returns: List of selected indices.
1607
+ """
1608
+ return sorted(MolGrid._selections.get(self.name, {}).keys())
1609
+
1610
+ def display(self):
1611
+ """Display the grid in the notebook.
1612
+
1613
+ Automatically detects Jupyter vs Marimo environment.
1614
+ Note: The widget was already displayed in __init__ to ensure
1615
+ the anywidget model is available when the iframe loads.
1616
+
1617
+ :returns: Displayable HTML object.
1618
+ """
1619
+ html_content = self.to_html()
1620
+ iframe_id = f"molgrid-iframe-{self.name}"
1621
+
1622
+ iframe_html = f'''<iframe
1623
+ id="{iframe_id}"
1624
+ class="molgrid-iframe"
1625
+ style="width: 100%; border: none; height: 500px; overflow: hidden;"
1626
+ srcdoc="{escape(html_content)}"
1627
+ ></iframe>'''
1628
+
1629
+ if _is_marimo():
1630
+ import marimo as mo
1631
+ return mo.vstack([self.widget, mo.Html(iframe_html)])
1632
+ else:
1633
+ return HTML(iframe_html)
1634
+
1635
+ def get_marimo_selection(self):
1636
+ """Get marimo reactive state for selection.
1637
+
1638
+ Only available in marimo environment.
1639
+
1640
+ :returns: State getter function.
1641
+ :raises RuntimeError: If not in marimo environment.
1642
+ """
1643
+ if not _is_marimo():
1644
+ raise RuntimeError("This method is only available in a marimo notebook.")
1645
+
1646
+ def get_state():
1647
+ return list(MolGrid._selections.get(self.name, {}).keys())
1648
+
1649
+ return get_state