cnotebook 1.2.0__py3-none-any.whl → 2.1.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.
cnotebook/grid/grid.py ADDED
@@ -0,0 +1,1655 @@
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: Union[bool, str, None] = True,
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: Title display mode. True uses molecule's title, a string
637
+ specifies a field name, None/False hides titles.
638
+ :param tooltip_fields: List of fields for tooltip display.
639
+ :param n_items_per_page: Number of molecules per page.
640
+ :param width: Image width in pixels.
641
+ :param height: Image height in pixels.
642
+ :param atom_label_font_scale: Scale factor for atom labels.
643
+ :param image_format: Image format ("svg" or "png").
644
+ :param select: Enable selection checkboxes.
645
+ :param information: Enable info button with hover tooltip.
646
+ :param data: Column(s) to display in info tooltip. If None, auto-detects
647
+ simple types (string, int, float) from DataFrame.
648
+ :param search_fields: Fields for text search.
649
+ :param name: Grid identifier.
650
+ """
651
+ self._molecules = list(mols)
652
+ self._dataframe = dataframe
653
+ self._mol_col = mol_col
654
+ self.title = title if title else None
655
+ self.tooltip_fields = tooltip_fields or []
656
+ self.n_items_per_page = n_items_per_page
657
+ self.selection_enabled = select
658
+ self.information_enabled = information
659
+ self.name = name
660
+
661
+ # Handle data parameter for info tooltip columns
662
+ if data is not None:
663
+ if isinstance(data, str):
664
+ self.information_fields = [data]
665
+ else:
666
+ self.information_fields = list(data)
667
+ elif dataframe is not None:
668
+ # Auto-detect simple type columns
669
+ self.information_fields = self._auto_detect_info_fields(dataframe, mol_col)
670
+ else:
671
+ self.information_fields = []
672
+
673
+ # Auto-detect search fields from DataFrame if not provided
674
+ if search_fields is None and dataframe is not None:
675
+ self.search_fields = self._auto_detect_search_fields(dataframe, mol_col)
676
+ else:
677
+ self.search_fields = search_fields
678
+
679
+ # Rendering settings
680
+ self.width = width
681
+ self.height = height
682
+ self.image_format = image_format
683
+ self.atom_label_font_scale = atom_label_font_scale
684
+
685
+ # Generate grid name
686
+ if self.name is None:
687
+ self.name = f"molgrid-{uuid.uuid4().hex[:8]}"
688
+
689
+ # Initialize selection storage
690
+ MolGrid._selections[self.name] = {}
691
+
692
+ # Create widget
693
+ self.widget = MolGridWidget(grid_id=self.name)
694
+
695
+ # Observe selection changes
696
+ self.widget.observe(self._on_selection_change, names=["selection"])
697
+
698
+ # Observe SMARTS query changes
699
+ self.widget.observe(self._on_smarts_query, names=["smarts_query"])
700
+
701
+ # Display widget immediately (critical for model availability)
702
+ # In Jupyter: display widget here so model is available when iframe loads
703
+ # In Marimo: widget is returned from display() method instead
704
+ if not _is_marimo():
705
+ display(self.widget)
706
+
707
+ def _auto_detect_search_fields(self, dataframe, mol_col: Optional[str]) -> List[str]:
708
+ """Auto-detect searchable text columns from DataFrame.
709
+
710
+ :param dataframe: DataFrame to inspect.
711
+ :param mol_col: Molecule column name to exclude.
712
+ :returns: List of searchable column names.
713
+ """
714
+ search_fields = []
715
+ for col in dataframe.columns:
716
+ # Skip the molecule column
717
+ if col == mol_col:
718
+ continue
719
+
720
+ dtype = dataframe[col].dtype
721
+ dtype_str = str(dtype).lower()
722
+
723
+ # Skip molecule dtypes (oepandas MoleculeDtype)
724
+ if "molecule" in dtype_str:
725
+ continue
726
+
727
+ # Skip numeric dtypes
728
+ if any(t in dtype_str for t in ["int", "float", "double", "decimal"]):
729
+ continue
730
+
731
+ # Include string/object columns that likely contain text
732
+ if "object" in dtype_str or "string" in dtype_str or "str" in dtype_str:
733
+ search_fields.append(col)
734
+ continue
735
+
736
+ # Also check if column contains string values (fallback for edge cases)
737
+ try:
738
+ first_valid = dataframe[col].dropna().iloc[0] if len(dataframe[col].dropna()) > 0 else None
739
+ if first_valid is not None and isinstance(first_valid, str):
740
+ search_fields.append(col)
741
+ except (IndexError, KeyError):
742
+ pass
743
+
744
+ return search_fields
745
+
746
+ def _auto_detect_info_fields(self, dataframe, mol_col: Optional[str]) -> List[str]:
747
+ """Auto-detect simple type columns from DataFrame for info tooltip.
748
+
749
+ Includes string, int, and float columns (excludes molecule columns).
750
+
751
+ :param dataframe: DataFrame to inspect.
752
+ :param mol_col: Molecule column name to exclude.
753
+ :returns: List of column names with simple types.
754
+ """
755
+ info_fields = []
756
+ for col in dataframe.columns:
757
+ # Skip the molecule column
758
+ if col == mol_col:
759
+ continue
760
+
761
+ dtype = dataframe[col].dtype
762
+ dtype_str = str(dtype).lower()
763
+
764
+ # Skip molecule dtypes (oepandas MoleculeDtype)
765
+ if "molecule" in dtype_str:
766
+ continue
767
+
768
+ # Include numeric types (int, float)
769
+ if any(t in dtype_str for t in ["int", "float", "double", "decimal"]):
770
+ info_fields.append(col)
771
+ continue
772
+
773
+ # Include string/object columns
774
+ if "object" in dtype_str or "string" in dtype_str or "str" in dtype_str:
775
+ info_fields.append(col)
776
+ continue
777
+
778
+ # Include category dtype
779
+ if "category" in dtype_str:
780
+ info_fields.append(col)
781
+ continue
782
+
783
+ return info_fields
784
+
785
+ def _on_selection_change(self, change):
786
+ """Handle selection change from widget.
787
+
788
+ :param change: Traitlet change dict with 'new' key.
789
+ """
790
+ try:
791
+ selection = json.loads(change["new"])
792
+ MolGrid._selections[self.name] = {int(k): v for k, v in selection.items()}
793
+ except (json.JSONDecodeError, ValueError, TypeError):
794
+ pass
795
+
796
+ def _on_smarts_query(self, change):
797
+ """Handle SMARTS query from widget.
798
+
799
+ Performs substructure search and sends matching indices back.
800
+
801
+ :param change: Traitlet change dict with 'new' key.
802
+ """
803
+ query = change["new"]
804
+ if not query:
805
+ # Empty query - return all indices
806
+ matches = list(range(len(self._molecules)))
807
+ else:
808
+ matches = self._search_smarts(query)
809
+
810
+ # Send results back to widget
811
+ self.widget.smarts_matches = json.dumps(matches)
812
+
813
+ def _search_smarts(self, smarts_pattern: str) -> List[int]:
814
+ """Search molecules by SMARTS pattern.
815
+
816
+ :param smarts_pattern: SMARTS pattern string.
817
+ :returns: List of indices of matching molecules.
818
+ """
819
+ matches = []
820
+ try:
821
+ ss = oechem.OESubSearch(smarts_pattern)
822
+ if not ss.IsValid():
823
+ return matches
824
+
825
+ for idx, mol in enumerate(self._molecules):
826
+ oechem.OEPrepareSearch(mol, ss)
827
+ if ss.SingleMatch(mol):
828
+ matches.append(idx)
829
+ except Exception:
830
+ # Invalid SMARTS or other error - return empty
831
+ pass
832
+
833
+ return matches
834
+
835
+ def _get_field_value(self, idx: int, mol, field: str):
836
+ """Get a field value from DataFrame column or molecule property.
837
+
838
+ :param idx: Row index in the dataframe.
839
+ :param mol: OpenEye molecule object.
840
+ :param field: Field name to retrieve.
841
+ :returns: Field value or None.
842
+ """
843
+ # Try DataFrame column first
844
+ if self._dataframe is not None and field in self._dataframe.columns:
845
+ return self._dataframe.iloc[idx][field]
846
+
847
+ # Fall back to molecule properties
848
+ if field == "Title":
849
+ return mol.GetTitle() or None
850
+ else:
851
+ return oechem.OEGetSDData(mol, field) or None
852
+
853
+ def _prepare_data(self) -> List[dict]:
854
+ """Prepare molecule data for template rendering.
855
+
856
+ :returns: List of dicts with molecule data for each item.
857
+ """
858
+ data = []
859
+ ctx = CNotebookContext(
860
+ width=self.width,
861
+ height=self.height,
862
+ image_format=self.image_format,
863
+ atom_label_font_scale=self.atom_label_font_scale,
864
+ title=False,
865
+ scope="local",
866
+ )
867
+
868
+ for idx, mol in enumerate(self._molecules):
869
+ item = {
870
+ "index": idx,
871
+ "title": None,
872
+ "mol_title": mol.GetTitle() if mol.IsValid() else None,
873
+ "tooltip": {},
874
+ "smiles": oechem.OEMolToSmiles(mol) if mol.IsValid() else "",
875
+ "img": oemol_to_html(mol, ctx=ctx),
876
+ }
877
+
878
+ # Extract title
879
+ if self.title is True:
880
+ # Use molecule's built-in title
881
+ item["title"] = mol.GetTitle() if mol.IsValid() else None
882
+ elif self.title:
883
+ # Use specified field name
884
+ item["title"] = self._get_field_value(idx, mol, self.title)
885
+
886
+ # Extract tooltip fields
887
+ for field in self.tooltip_fields:
888
+ item["tooltip"][field] = self._get_field_value(idx, mol, field)
889
+
890
+ # Extract search fields
891
+ item["search_fields"] = {}
892
+ if self.search_fields:
893
+ for field in self.search_fields:
894
+ item["search_fields"][field] = self._get_field_value(idx, mol, field)
895
+
896
+ # Extract information fields for tooltip
897
+ item["info_fields"] = {}
898
+ if self.information_fields:
899
+ for field in self.information_fields:
900
+ item["info_fields"][field] = self._get_field_value(idx, mol, field)
901
+
902
+ data.append(item)
903
+
904
+ return data
905
+
906
+ def _prepare_export_data(self) -> List[dict]:
907
+ """Prepare molecule data for CSV/SMILES export.
908
+
909
+ :returns: List of dicts with all exportable data for each molecule.
910
+ """
911
+ export_data = []
912
+
913
+ # Determine columns to export
914
+ if self._dataframe is not None:
915
+ columns = [c for c in self._dataframe.columns if c != self._mol_col]
916
+ else:
917
+ columns = []
918
+
919
+ for idx, mol in enumerate(self._molecules):
920
+ row = {
921
+ "index": idx,
922
+ "smiles": oechem.OEMolToSmiles(mol) if mol.IsValid() else "",
923
+ }
924
+
925
+ # Add DataFrame columns
926
+ if self._dataframe is not None:
927
+ for col in columns:
928
+ val = self._dataframe.iloc[idx][col]
929
+ # Convert to string for JSON serialization
930
+ if val is None or (hasattr(val, '__len__') and len(str(val)) == 0):
931
+ row[col] = ""
932
+ else:
933
+ row[col] = str(val)
934
+
935
+ export_data.append(row)
936
+
937
+ return export_data
938
+
939
+ def to_html(self) -> str:
940
+ """Generate HTML representation of the grid.
941
+
942
+ :returns: Complete HTML document as string.
943
+ """
944
+ items = self._prepare_data()
945
+ export_data = self._prepare_export_data()
946
+ grid_id = self.name
947
+ items_per_page = self.n_items_per_page
948
+ total_items = len(items)
949
+
950
+ # Prepare export data as JSON for JavaScript
951
+ export_data_js = json.dumps(export_data)
952
+ # Get column names for CSV header
953
+ export_columns = ["smiles"]
954
+ if self._dataframe is not None:
955
+ export_columns.extend([c for c in self._dataframe.columns if c != self._mol_col])
956
+ export_columns_js = json.dumps(export_columns)
957
+
958
+ # Build item HTML - IMPORTANT: data-smiles on cell element for working selection
959
+ items_html = ""
960
+ for item in items:
961
+ tooltip_str = ""
962
+ if item["tooltip"]:
963
+ tooltip_parts = [f"{k}: {v}" for k, v in item["tooltip"].items() if v]
964
+ tooltip_str = "
".join(tooltip_parts)
965
+
966
+ # Visible title display
967
+ title_display_html = ""
968
+ if item["title"]:
969
+ title_display_html = f'<div class="molgrid-title">{escape(str(item["title"]))}</div>'
970
+
971
+ # Hidden title span for List.js search (always present for consistent indexing)
972
+ title_value = escape(str(item["title"])) if item["title"] else ""
973
+
974
+ checkbox_html = ""
975
+ if self.selection_enabled:
976
+ checkbox_html = f'<input type="checkbox" class="molgrid-checkbox" data-index="{item["index"]}">'
977
+
978
+ # Info button with tooltip
979
+ info_html = ""
980
+ if self.information_enabled:
981
+ # Build tooltip content: Index always, Title if available, then data fields
982
+ 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>'
983
+ if item.get("mol_title"):
984
+ 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>'
985
+ # Add data fields from info_fields
986
+ for field, value in item.get("info_fields", {}).items():
987
+ if value is not None:
988
+ display_value = escape(str(value))
989
+ 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>'
990
+ info_html = f'''<button class="molgrid-info-btn" type="button">i</button>
991
+ <div class="molgrid-info-tooltip">{info_rows}</div>'''
992
+
993
+ tooltip_attr = f'title="{tooltip_str}"' if tooltip_str else ""
994
+
995
+ # Build hidden spans for search fields
996
+ search_fields_html = ""
997
+ for field, value in item["search_fields"].items():
998
+ safe_value = escape(str(value)) if value else ""
999
+ search_fields_html += f'<span class="{escape(field)}" style="display:none;">{safe_value}</span>\n '
1000
+
1001
+ # Keep data-smiles on cell element (critical for selection to work)
1002
+ # Add hidden spans for List.js valueNames (index, smiles, title, search fields)
1003
+ items_html += f'''
1004
+ <li class="molgrid-cell" data-index="{item["index"]}" data-smiles="{escape(item["smiles"])}" {tooltip_attr}>
1005
+ {checkbox_html}
1006
+ {info_html}
1007
+ <div class="molgrid-image">{item["img"]}</div>
1008
+ {title_display_html}
1009
+ <span class="title" style="display:none;">{title_value}</span>
1010
+ <span class="index" style="display:none;">{item["index"]}</span>
1011
+ <span class="smiles" style="display:none;">{escape(item["smiles"])}</span>
1012
+ {search_fields_html}
1013
+ </li>
1014
+ '''
1015
+
1016
+ # Prepare search fields for JavaScript
1017
+ search_fields_js = json.dumps(self.search_fields or [])
1018
+
1019
+ # JavaScript for selection sync with List.js pagination
1020
+ js = f'''
1021
+ (function() {{
1022
+ var gridId = "{grid_id}";
1023
+ var itemsPerPage = {items_per_page};
1024
+ var searchFields = {search_fields_js};
1025
+ var exportData = {export_data_js};
1026
+ var exportColumns = {export_columns_js};
1027
+ var container = document.getElementById(gridId);
1028
+ var selectedIndices = new Set();
1029
+ var searchMode = 'properties'; // 'properties' or 'smarts'
1030
+
1031
+ // Initialize List.js with pagination
1032
+ var options = {{
1033
+ valueNames: ['title', 'index', 'smiles'].concat(searchFields),
1034
+ page: itemsPerPage,
1035
+ pagination: {{
1036
+ innerWindow: 1,
1037
+ outerWindow: 1,
1038
+ left: 1,
1039
+ right: 1,
1040
+ paginationClass: 'pagination',
1041
+ item: '<li><a class="page" href="javascript:void(0)"></a></li>'
1042
+ }}
1043
+ }};
1044
+
1045
+ var molgridList = new List(gridId, options);
1046
+
1047
+ // Prev/Next buttons
1048
+ var prevBtn = container.querySelector('.molgrid-prev');
1049
+ var nextBtn = container.querySelector('.molgrid-next');
1050
+
1051
+ // Update showing info text and prev/next button states
1052
+ function updateShowingInfo() {{
1053
+ var totalItems = molgridList.matchingItems.length;
1054
+ var currentPage = molgridList.i;
1055
+ var perPage = molgridList.page;
1056
+
1057
+ var start = totalItems > 0 ? currentPage : 0;
1058
+ var end = Math.min(currentPage + perPage - 1, totalItems);
1059
+
1060
+ var showingStart = container.querySelector('.showing-start');
1061
+ var showingEnd = container.querySelector('.showing-end');
1062
+ var showingTotal = container.querySelector('.showing-total');
1063
+
1064
+ if (showingStart) showingStart.textContent = start;
1065
+ if (showingEnd) showingEnd.textContent = end;
1066
+ if (showingTotal) showingTotal.textContent = totalItems;
1067
+
1068
+ // Update prev/next button states
1069
+ var totalPages = Math.ceil(totalItems / perPage);
1070
+ var currentPageNum = Math.ceil(currentPage / perPage);
1071
+
1072
+ prevBtn.disabled = currentPageNum <= 1;
1073
+ nextBtn.disabled = currentPageNum >= totalPages || totalPages <= 1;
1074
+ }}
1075
+
1076
+ // Prev button click handler
1077
+ prevBtn.addEventListener('click', function() {{
1078
+ var totalItems = molgridList.matchingItems.length;
1079
+ var currentPage = molgridList.i;
1080
+ var perPage = molgridList.page;
1081
+
1082
+ if (currentPage > 1) {{
1083
+ var newStart = currentPage - perPage;
1084
+ if (newStart < 1) newStart = 1;
1085
+ molgridList.show(newStart, perPage);
1086
+ }}
1087
+ }});
1088
+
1089
+ // Next button click handler
1090
+ nextBtn.addEventListener('click', function() {{
1091
+ var totalItems = molgridList.matchingItems.length;
1092
+ var currentPage = molgridList.i;
1093
+ var perPage = molgridList.page;
1094
+ var totalPages = Math.ceil(totalItems / perPage);
1095
+ var currentPageNum = Math.ceil(currentPage / perPage);
1096
+
1097
+ if (currentPageNum < totalPages) {{
1098
+ var newStart = currentPage + perPage;
1099
+ molgridList.show(newStart, perPage);
1100
+ }}
1101
+ }});
1102
+
1103
+ // Search functionality
1104
+ var searchInput = container.querySelector('.molgrid-search-input');
1105
+ var searchModeToggle = container.querySelector('.search-mode-toggle');
1106
+ var toggleTexts = container.querySelectorAll('.toggle-text');
1107
+ var smartsMatchIndices = null; // Store SMARTS match results
1108
+
1109
+ // Debounce function for search
1110
+ function debounce(func, wait) {{
1111
+ var timeout;
1112
+ return function() {{
1113
+ var context = this;
1114
+ var args = arguments;
1115
+ clearTimeout(timeout);
1116
+ timeout = setTimeout(function() {{
1117
+ func.apply(context, args);
1118
+ }}, wait);
1119
+ }};
1120
+ }}
1121
+
1122
+ // Apply SMARTS filter based on matching indices
1123
+ function applySmartsFilter(matchIndices) {{
1124
+ smartsMatchIndices = new Set(matchIndices);
1125
+ molgridList.filter(function(item) {{
1126
+ var idx = parseInt(item.values().index, 10);
1127
+ return smartsMatchIndices.has(idx);
1128
+ }});
1129
+ molgridList.i = 1; // Reset to first page
1130
+ updateShowingInfo();
1131
+ }}
1132
+
1133
+ // Clear SMARTS filter
1134
+ function clearSmartsFilter() {{
1135
+ smartsMatchIndices = null;
1136
+ molgridList.filter(); // Clear filter
1137
+ molgridList.i = 1;
1138
+ updateShowingInfo();
1139
+ }}
1140
+
1141
+ // Send SMARTS query to Python
1142
+ function sendSmartsQuery(query) {{
1143
+ window.parent.postMessage({{
1144
+ type: "MOLGRID_SMARTS_QUERY",
1145
+ gridId: gridId,
1146
+ query: query
1147
+ }}, "*");
1148
+ }}
1149
+
1150
+ // Listen for SMARTS results from Python
1151
+ window.addEventListener("message", function(event) {{
1152
+ if (event.data && event.data.gridId === gridId && event.data.type === "MOLGRID_SMARTS_RESULTS") {{
1153
+ var matches = JSON.parse(event.data.matches);
1154
+ applySmartsFilter(matches);
1155
+ }}
1156
+ }});
1157
+
1158
+ // Perform search based on current mode
1159
+ function performSearch(query) {{
1160
+ if (!query) {{
1161
+ molgridList.search(); // Clear text search
1162
+ clearSmartsFilter(); // Clear SMARTS filter
1163
+ }} else if (searchMode === 'smarts') {{
1164
+ // SMARTS mode - send to Python for substructure search
1165
+ molgridList.search(); // Clear any text search
1166
+ sendSmartsQuery(query);
1167
+ }} else {{
1168
+ // Properties mode - search title and configured fields
1169
+ clearSmartsFilter(); // Clear any SMARTS filter
1170
+ var fieldsToSearch = ['title'].concat(searchFields);
1171
+ molgridList.search(query, fieldsToSearch);
1172
+ molgridList.i = 1; // Reset to first page
1173
+ updateShowingInfo();
1174
+ }}
1175
+ }}
1176
+
1177
+ // Debounced search handler
1178
+ var debouncedSearch = debounce(function(e) {{
1179
+ performSearch(e.target.value);
1180
+ }}, 300);
1181
+
1182
+ searchInput.addEventListener('input', debouncedSearch);
1183
+
1184
+ // Search mode toggle handler
1185
+ searchModeToggle.addEventListener('change', function() {{
1186
+ searchMode = this.checked ? 'smarts' : 'properties';
1187
+
1188
+ // Update toggle text styling
1189
+ toggleTexts[0].classList.toggle('active', !this.checked);
1190
+ toggleTexts[1].classList.toggle('active', this.checked);
1191
+
1192
+ // Re-trigger search with current query
1193
+ if (searchInput.value) {{
1194
+ performSearch(searchInput.value);
1195
+ }} else {{
1196
+ // Clear any existing filter when switching modes with empty query
1197
+ clearSmartsFilter();
1198
+ }}
1199
+ }});
1200
+
1201
+ // Update checkboxes based on selection state (after page change)
1202
+ function updateCheckboxes() {{
1203
+ var checkboxes = container.querySelectorAll('.molgrid-checkbox');
1204
+ checkboxes.forEach(function(checkbox) {{
1205
+ var index = parseInt(checkbox.getAttribute('data-index'), 10);
1206
+ checkbox.checked = selectedIndices.has(index);
1207
+ var cell = checkbox.closest('.molgrid-cell');
1208
+ if (cell) {{
1209
+ cell.classList.toggle('selected', checkbox.checked);
1210
+ }}
1211
+ }});
1212
+ }}
1213
+
1214
+ function syncSelection() {{
1215
+ var selection = {{}};
1216
+ selectedIndices.forEach(function(idx) {{
1217
+ var cell = container.querySelector('.molgrid-cell[data-index="' + idx + '"]');
1218
+ var smiles = cell ? cell.getAttribute("data-smiles") : '';
1219
+ selection[idx] = smiles;
1220
+ }});
1221
+
1222
+ var globalName = "_MOLGRID_" + gridId;
1223
+ var model = window.parent[globalName];
1224
+
1225
+ if (model) {{
1226
+ model.set("selection", JSON.stringify(selection));
1227
+ model.save_changes();
1228
+ }} else {{
1229
+ window.parent.postMessage({{
1230
+ type: "MOLGRID_SELECTION",
1231
+ gridId: gridId,
1232
+ selection: selection
1233
+ }}, "*");
1234
+ }}
1235
+ }}
1236
+
1237
+ // Listen for List.js updates (page changes, filtering)
1238
+ molgridList.on('updated', function() {{
1239
+ updateShowingInfo();
1240
+ updateCheckboxes();
1241
+ }});
1242
+
1243
+ container.addEventListener("change", function(e) {{
1244
+ if (e.target.classList.contains("molgrid-checkbox")) {{
1245
+ var index = parseInt(e.target.getAttribute("data-index"), 10);
1246
+ var cell = e.target.closest(".molgrid-cell");
1247
+
1248
+ if (e.target.checked) {{
1249
+ selectedIndices.add(index);
1250
+ cell.classList.add("selected");
1251
+ }} else {{
1252
+ selectedIndices.delete(index);
1253
+ cell.classList.remove("selected");
1254
+ }}
1255
+
1256
+ syncSelection();
1257
+ }}
1258
+ }});
1259
+
1260
+ container.addEventListener("click", function(e) {{
1261
+ var cell = e.target.closest(".molgrid-cell");
1262
+ // Don't toggle selection when clicking checkbox, info button, or tooltip
1263
+ var isCheckbox = e.target.classList.contains("molgrid-checkbox");
1264
+ var isInfoBtn = e.target.classList.contains("molgrid-info-btn");
1265
+ var isInfoTooltip = e.target.closest(".molgrid-info-tooltip");
1266
+ if (cell && !isCheckbox && !isInfoBtn && !isInfoTooltip) {{
1267
+ var checkbox = cell.querySelector(".molgrid-checkbox");
1268
+ if (checkbox) {{
1269
+ checkbox.checked = !checkbox.checked;
1270
+ checkbox.dispatchEvent(new Event("change", {{ bubbles: true }}));
1271
+ }}
1272
+ }}
1273
+ }});
1274
+
1275
+ // Prevent default on pagination links (fixes Marimo navigation issue)
1276
+ container.querySelector('.molgrid-pagination').addEventListener('click', function(e) {{
1277
+ if (e.target.tagName === 'A') {{
1278
+ e.preventDefault();
1279
+ }}
1280
+ }});
1281
+
1282
+ // Send height to parent for proper iframe sizing
1283
+ function sendHeight() {{
1284
+ var height = document.body.scrollHeight;
1285
+ window.parent.postMessage({{
1286
+ type: "MOLGRID_HEIGHT",
1287
+ gridId: gridId,
1288
+ height: height
1289
+ }}, "*");
1290
+ }}
1291
+
1292
+ // Initial update
1293
+ updateShowingInfo();
1294
+
1295
+ // Send initial height after a short delay for rendering
1296
+ setTimeout(sendHeight, 100);
1297
+
1298
+ // Also send height after List.js updates
1299
+ molgridList.on('updated', function() {{
1300
+ setTimeout(sendHeight, 50);
1301
+ }});
1302
+
1303
+ // ========================================
1304
+ // Info Button Click-to-Pin
1305
+ // ========================================
1306
+
1307
+ // Handle info button clicks to pin/unpin tooltips
1308
+ container.addEventListener('click', function(e) {{
1309
+ if (e.target.classList.contains('molgrid-info-btn')) {{
1310
+ e.stopPropagation();
1311
+ var btn = e.target;
1312
+ var tooltip = btn.nextElementSibling;
1313
+ if (tooltip && tooltip.classList.contains('molgrid-info-tooltip')) {{
1314
+ var isPinned = tooltip.classList.contains('pinned');
1315
+ if (isPinned) {{
1316
+ // Unpin this tooltip
1317
+ tooltip.classList.remove('pinned');
1318
+ btn.classList.remove('active');
1319
+ }} else {{
1320
+ // Pin this tooltip (don't unpin others - allow comparison)
1321
+ tooltip.classList.add('pinned');
1322
+ btn.classList.add('active');
1323
+ }}
1324
+ }}
1325
+ }}
1326
+ }});
1327
+
1328
+ // ========================================
1329
+ // Actions Dropdown
1330
+ // ========================================
1331
+
1332
+ var actionsBtn = container.querySelector('.molgrid-actions-btn');
1333
+ var dropdown = container.querySelector('.molgrid-dropdown');
1334
+
1335
+ // Position and toggle dropdown
1336
+ function positionDropdown() {{
1337
+ var rect = actionsBtn.getBoundingClientRect();
1338
+ var dropdownHeight = dropdown.offsetHeight || 250;
1339
+ var viewportHeight = window.innerHeight;
1340
+
1341
+ // Check if there's room below the button
1342
+ var spaceBelow = viewportHeight - rect.bottom;
1343
+ var spaceAbove = rect.top;
1344
+
1345
+ if (spaceBelow >= dropdownHeight || spaceBelow >= spaceAbove) {{
1346
+ // Position below
1347
+ dropdown.style.top = rect.bottom + 4 + 'px';
1348
+ dropdown.style.bottom = 'auto';
1349
+ }} else {{
1350
+ // Position above
1351
+ dropdown.style.bottom = (viewportHeight - rect.top + 4) + 'px';
1352
+ dropdown.style.top = 'auto';
1353
+ }}
1354
+ // Always align to right edge of viewport with 10px margin
1355
+ dropdown.style.right = '10px';
1356
+ dropdown.style.left = 'auto';
1357
+ }}
1358
+
1359
+ // Toggle dropdown
1360
+ actionsBtn.addEventListener('click', function(e) {{
1361
+ e.stopPropagation();
1362
+ var isShowing = dropdown.classList.contains('show');
1363
+ if (!isShowing) {{
1364
+ positionDropdown();
1365
+ }}
1366
+ dropdown.classList.toggle('show');
1367
+ }});
1368
+
1369
+ // Reposition on scroll/resize
1370
+ window.addEventListener('scroll', function() {{
1371
+ if (dropdown.classList.contains('show')) {{
1372
+ positionDropdown();
1373
+ }}
1374
+ }});
1375
+
1376
+ // Close dropdown when clicking outside
1377
+ document.addEventListener('click', function(e) {{
1378
+ if (!dropdown.contains(e.target) && e.target !== actionsBtn) {{
1379
+ dropdown.classList.remove('show');
1380
+ }}
1381
+ }});
1382
+
1383
+ // Get all item indices (respecting current filter)
1384
+ function getAllIndices() {{
1385
+ return molgridList.items.map(function(item) {{
1386
+ return parseInt(item.values().index, 10);
1387
+ }});
1388
+ }}
1389
+
1390
+ // Get matching item indices (respecting current filter/search)
1391
+ function getMatchingIndices() {{
1392
+ return molgridList.matchingItems.map(function(item) {{
1393
+ return parseInt(item.values().index, 10);
1394
+ }});
1395
+ }}
1396
+
1397
+ // Select All action
1398
+ function selectAll() {{
1399
+ var indices = getMatchingIndices();
1400
+ indices.forEach(function(idx) {{
1401
+ selectedIndices.add(idx);
1402
+ }});
1403
+ updateCheckboxes();
1404
+ syncSelection();
1405
+ }}
1406
+
1407
+ // Clear Selection action
1408
+ function clearSelection() {{
1409
+ selectedIndices.clear();
1410
+ updateCheckboxes();
1411
+ syncSelection();
1412
+ }}
1413
+
1414
+ // Invert Selection action
1415
+ function invertSelection() {{
1416
+ var indices = getMatchingIndices();
1417
+ indices.forEach(function(idx) {{
1418
+ if (selectedIndices.has(idx)) {{
1419
+ selectedIndices.delete(idx);
1420
+ }} else {{
1421
+ selectedIndices.add(idx);
1422
+ }}
1423
+ }});
1424
+ updateCheckboxes();
1425
+ syncSelection();
1426
+ }}
1427
+
1428
+ // CSV escape helper
1429
+ function csvEscape(val) {{
1430
+ if (val === null || val === undefined) return '';
1431
+ var str = String(val);
1432
+ if (str.indexOf(',') !== -1 || str.indexOf('"') !== -1 || str.indexOf('\\n') !== -1) {{
1433
+ return '"' + str.replace(/"/g, '""') + '"';
1434
+ }}
1435
+ return str;
1436
+ }}
1437
+
1438
+ // Get data for export (selected or all)
1439
+ function getExportRows() {{
1440
+ var indices = selectedIndices.size > 0
1441
+ ? Array.from(selectedIndices).sort(function(a, b) {{ return a - b; }})
1442
+ : getMatchingIndices();
1443
+ return indices.map(function(idx) {{
1444
+ return exportData[idx];
1445
+ }});
1446
+ }}
1447
+
1448
+ // Generate CSV content
1449
+ function generateCSV() {{
1450
+ var rows = getExportRows();
1451
+ var lines = [];
1452
+ // Header
1453
+ lines.push(exportColumns.map(csvEscape).join(','));
1454
+ // Data rows
1455
+ rows.forEach(function(row) {{
1456
+ var values = exportColumns.map(function(col) {{
1457
+ return csvEscape(row[col] || '');
1458
+ }});
1459
+ lines.push(values.join(','));
1460
+ }});
1461
+ return lines.join('\\n');
1462
+ }}
1463
+
1464
+ // Generate SMILES content
1465
+ function generateSMILES() {{
1466
+ var rows = getExportRows();
1467
+ return rows.map(function(row) {{
1468
+ return row.smiles || '';
1469
+ }}).filter(function(s) {{ return s; }}).join('\\n');
1470
+ }}
1471
+
1472
+ // Copy to Clipboard action
1473
+ function copyToClipboard() {{
1474
+ var csv = generateCSV();
1475
+ navigator.clipboard.writeText(csv).then(function() {{
1476
+ // Optional: show feedback
1477
+ }}).catch(function(err) {{
1478
+ console.error('Failed to copy:', err);
1479
+ }});
1480
+ }}
1481
+
1482
+ // Save file helper
1483
+ function saveFile(content, filename, mimeType) {{
1484
+ var blob = new Blob([content], {{ type: mimeType }});
1485
+ var url = URL.createObjectURL(blob);
1486
+ var a = document.createElement('a');
1487
+ a.href = url;
1488
+ a.download = filename;
1489
+ document.body.appendChild(a);
1490
+ a.click();
1491
+ document.body.removeChild(a);
1492
+ URL.revokeObjectURL(url);
1493
+ }}
1494
+
1495
+ // Save to SMILES action
1496
+ function saveToSMILES() {{
1497
+ var content = generateSMILES();
1498
+ saveFile(content, 'molgrid.smi', 'chemical/x-daylight-smiles');
1499
+ }}
1500
+
1501
+ // Save to CSV action
1502
+ function saveToCSV() {{
1503
+ var content = generateCSV();
1504
+ saveFile(content, 'molgrid.csv', 'text/csv');
1505
+ }}
1506
+
1507
+ // Handle dropdown item clicks
1508
+ dropdown.addEventListener('click', function(e) {{
1509
+ var item = e.target.closest('.molgrid-dropdown-item');
1510
+ if (!item) return;
1511
+
1512
+ var action = item.dataset.action;
1513
+ dropdown.classList.remove('show');
1514
+
1515
+ switch (action) {{
1516
+ case 'select-all':
1517
+ selectAll();
1518
+ break;
1519
+ case 'clear-selection':
1520
+ clearSelection();
1521
+ break;
1522
+ case 'invert-selection':
1523
+ invertSelection();
1524
+ break;
1525
+ case 'copy-clipboard':
1526
+ copyToClipboard();
1527
+ break;
1528
+ case 'save-smiles':
1529
+ saveToSMILES();
1530
+ break;
1531
+ case 'save-csv':
1532
+ saveToCSV();
1533
+ break;
1534
+ }}
1535
+ }});
1536
+ }})();
1537
+ '''
1538
+
1539
+ return f'''<!DOCTYPE html>
1540
+ <html lang="en">
1541
+ <head>
1542
+ <meta charset="UTF-8">
1543
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1544
+ <title>MolGrid</title>
1545
+ <script>{_LIST_JS}</script>
1546
+ <style>
1547
+ :root {{
1548
+ --molgrid-image-width: {self.width}px;
1549
+ --molgrid-image-height: {self.height}px;
1550
+ --molgrid-cell-width: {self.width + 24}px;
1551
+ }}
1552
+ {_CSS}
1553
+ </style>
1554
+ </head>
1555
+ <body>
1556
+ <div id="{grid_id}" class="molgrid-container">
1557
+ <div class="molgrid-toolbar">
1558
+ <div class="molgrid-search">
1559
+ <input type="text" class="molgrid-search-input" placeholder="Search...">
1560
+ <div class="toggle-switch">
1561
+ <label class="toggle-label">
1562
+ <span class="toggle-text active">Properties</span>
1563
+ <input type="checkbox" class="search-mode-toggle">
1564
+ <span class="toggle-text">SMARTS</span>
1565
+ </label>
1566
+ </div>
1567
+ </div>
1568
+ <div class="molgrid-info">
1569
+ 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
1570
+ </div>
1571
+ <div class="molgrid-actions">
1572
+ <button class="molgrid-actions-btn" title="Actions">&#8943;</button>
1573
+ <div class="molgrid-dropdown">
1574
+ <button class="molgrid-dropdown-item" data-action="select-all">Select All</button>
1575
+ <button class="molgrid-dropdown-item" data-action="clear-selection">Clear Selection</button>
1576
+ <button class="molgrid-dropdown-item" data-action="invert-selection">Invert Selection</button>
1577
+ <div class="molgrid-dropdown-divider"></div>
1578
+ <button class="molgrid-dropdown-item" data-action="copy-clipboard">Copy to Clipboard</button>
1579
+ <button class="molgrid-dropdown-item" data-action="save-smiles">Save to SMILES</button>
1580
+ <button class="molgrid-dropdown-item" data-action="save-csv">Save to CSV</button>
1581
+ </div>
1582
+ </div>
1583
+ </div>
1584
+ <ul class="molgrid-list list">
1585
+ {items_html}
1586
+ </ul>
1587
+ <div class="molgrid-pagination-nav">
1588
+ <button class="molgrid-prev" disabled>&laquo; Previous</button>
1589
+ <div class="molgrid-pagination">
1590
+ <ul class="pagination"></ul>
1591
+ </div>
1592
+ <button class="molgrid-next">Next &raquo;</button>
1593
+ </div>
1594
+ </div>
1595
+ <script>
1596
+ {js}
1597
+ </script>
1598
+ </body>
1599
+ </html>'''
1600
+
1601
+ def get_selection(self) -> List:
1602
+ """Get list of selected molecules.
1603
+
1604
+ :returns: List of selected OEMol objects.
1605
+ """
1606
+ selection = MolGrid._selections.get(self.name, {})
1607
+ return [self._molecules[idx] for idx in sorted(selection.keys())]
1608
+
1609
+ def get_selection_indices(self) -> List[int]:
1610
+ """Get indices of selected molecules.
1611
+
1612
+ :returns: List of selected indices.
1613
+ """
1614
+ return sorted(MolGrid._selections.get(self.name, {}).keys())
1615
+
1616
+ def display(self):
1617
+ """Display the grid in the notebook.
1618
+
1619
+ Automatically detects Jupyter vs Marimo environment.
1620
+ Note: The widget was already displayed in __init__ to ensure
1621
+ the anywidget model is available when the iframe loads.
1622
+
1623
+ :returns: Displayable HTML object.
1624
+ """
1625
+ html_content = self.to_html()
1626
+ iframe_id = f"molgrid-iframe-{self.name}"
1627
+
1628
+ iframe_html = f'''<iframe
1629
+ id="{iframe_id}"
1630
+ class="molgrid-iframe"
1631
+ style="width: 100%; border: none; height: 500px; overflow: hidden;"
1632
+ srcdoc="{escape(html_content)}"
1633
+ ></iframe>'''
1634
+
1635
+ if _is_marimo():
1636
+ import marimo as mo
1637
+ return mo.vstack([self.widget, mo.Html(iframe_html)])
1638
+ else:
1639
+ return HTML(iframe_html)
1640
+
1641
+ def get_marimo_selection(self):
1642
+ """Get marimo reactive state for selection.
1643
+
1644
+ Only available in marimo environment.
1645
+
1646
+ :returns: State getter function.
1647
+ :raises RuntimeError: If not in marimo environment.
1648
+ """
1649
+ if not _is_marimo():
1650
+ raise RuntimeError("This method is only available in a marimo notebook.")
1651
+
1652
+ def get_state():
1653
+ return list(MolGrid._selections.get(self.name, {}).keys())
1654
+
1655
+ return get_state