Skip to content

Commit f3e99a2

Browse files
committed
Add atomic NicePool display and preset initialization
1 parent b2cbf82 commit f3e99a2

14 files changed

Lines changed: 304 additions & 32 deletions

File tree

‎integrations/nicepool-pyqt5/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ The adapter is storage-agnostic. To persist UI-created workspaces, connect
3232
list with `set_presets()` after the initial `data_reset`. See
3333
`examples/preset_persistence.py`.
3434

35+
Apply display-only startup options in the constructor so the first browser
36+
render already has the intended appearance:
37+
38+
```python
39+
pool = NicePoolWidget(
40+
theme="dark",
41+
controls_collapsed=True,
42+
preset_editing_visible=True,
43+
)
44+
```
45+
46+
The corresponding setters remain available for runtime changes.
47+
3548
## Run tests and the example
3649

3750
```bash

‎integrations/nicepool-pyqt5/examples/demo.py‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,65 @@ def main() -> int:
3030
"""Launch the demo window."""
3131
application = QApplication(sys.argv)
3232
window = QMainWindow()
33-
pool = NicePoolWidget()
33+
pool = NicePoolWidget(controls_collapsed=True)
3434
pool.error_occurred.connect(print)
3535
pool.selection_changed.connect(lambda selection: print("Selection:", selection))
36-
pool.set_dataframe(sample_dataframe(), row_id_column="pool_row_id")
36+
pool.set_dataframe(
37+
sample_dataframe(),
38+
row_id_column="pool_row_id",
39+
schema=[
40+
{"name": "pool_row_id", "type": "string", "axis_label": "Row"},
41+
{
42+
"name": "condition",
43+
"type": "string",
44+
"axis_label": "Condition",
45+
"categorical": True,
46+
},
47+
{
48+
"name": "accept",
49+
"type": "string",
50+
"axis_label": "Accepted",
51+
"categorical": True,
52+
},
53+
{"name": "time", "type": "number", "axis_label": "Time"},
54+
{
55+
"name": "amplitude",
56+
"type": "number",
57+
"axis_label": "Amplitude",
58+
},
59+
],
60+
preset_definitions=[
61+
{
62+
"name": "Amplitude over time",
63+
"state": {
64+
"layout": "1x1",
65+
"plots": [
66+
{
67+
"plotType": "scatter",
68+
"xColumn": "time",
69+
"yColumn": "amplitude",
70+
"colorColumn": "condition",
71+
}
72+
],
73+
},
74+
},
75+
{
76+
"name": "Amplitude by condition",
77+
"state": {
78+
"layout": "1x1",
79+
"plots": [
80+
{
81+
"plotType": "swarm",
82+
"xColumn": "time",
83+
"yColumn": "amplitude",
84+
"groupColumn": "condition",
85+
}
86+
],
87+
},
88+
},
89+
],
90+
default_preset="Amplitude over time",
91+
)
3792
window.setCentralWidget(pool)
3893
window.resize(1200, 800)
3994
window.setWindowTitle("NicePool PyQt5")
@@ -43,4 +98,3 @@ def main() -> int:
4398

4499
if __name__ == "__main__":
45100
raise SystemExit(main())
46-

‎integrations/nicepool-pyqt5/frontend/main.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
registerNicePoolElement,
33
type DatasetInput,
44
type NicePoolPreset,
5+
type NicePoolPresetDefinition,
56
type NicePoolSelection,
67
type NicePoolState,
78
type NicePoolTheme,
@@ -26,6 +27,7 @@ interface QtNamespace {
2627

2728
interface NicePoolDomElement extends HTMLElement {
2829
setData(input: DatasetInput): void
30+
initializeData(input: DatasetInput, definitions: readonly NicePoolPresetDefinition[], defaultPresetName?: string | null): void
2931
replaceData(input: DatasetInput): void
3032
setState(state: NicePoolState): void
3133
getState(): NicePoolState
@@ -51,6 +53,12 @@ interface CommandEnvelope {
5153
payload?: unknown
5254
}
5355

56+
interface InitializeDataPayload {
57+
dataset: DatasetInput
58+
presetDefinitions: NicePoolPresetDefinition[]
59+
defaultPresetName?: string | null
60+
}
61+
5462
declare global {
5563
interface Window {
5664
qt: QtNamespace
@@ -68,8 +76,15 @@ if (typeof globalThis.structuredClone !== 'function') {
6876

6977
registerNicePoolElement()
7078

71-
const pool = document.querySelector<NicePoolDomElement>('nice-pool')
72-
if (!pool) throw new Error('The NicePool host page is missing its <nice-pool> element')
79+
const host = document.querySelector<HTMLElement>('#nicepool-host')
80+
if (!host) throw new Error('The NicePool host page is missing its mount element')
81+
const parameters = new URLSearchParams(window.location.search)
82+
const pool = document.createElement('nice-pool') as NicePoolDomElement
83+
const initialTheme = parameters.get('theme')
84+
if (initialTheme === 'dark' || initialTheme === 'light') pool.setTheme(initialTheme)
85+
pool.setControlsCollapsed(parameters.get('controlsCollapsed') === 'true')
86+
pool.setShowPresetEditing(parameters.get('presetEditingVisible') !== 'false')
87+
host.append(pool)
7388

7489
function errorText(reason: unknown): string {
7590
return reason instanceof Error ? reason.message : String(reason)
@@ -108,6 +123,10 @@ new window.QWebChannel(window.qt.webChannelTransport, (channel) => {
108123
const execute = (command: CommandEnvelope): unknown => {
109124
switch (command.name) {
110125
case 'setData': return pool.setData(command.payload as DatasetInput)
126+
case 'initializeData': {
127+
const payload = command.payload as InitializeDataPayload
128+
return pool.initializeData(payload.dataset, payload.presetDefinitions, payload.defaultPresetName)
129+
}
111130
case 'replaceData': return pool.replaceData(command.payload as DatasetInput)
112131
case 'setState': return pool.setState(command.payload as NicePoolState)
113132
case 'getState': return pool.getState()

‎integrations/nicepool-pyqt5/src/nicepool_pyqt5/static/index.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
<title>NicePool PyQt5</title>
77
<link rel="stylesheet" href="./nicepool-pyqt5.css" />
88
<style>
9-
html, body, nice-pool { display: block; width: 100%; height: 100%; margin: 0; }
9+
html, body, #nicepool-host, nice-pool { display: block; width: 100%; height: 100%; margin: 0; }
1010
body { overflow: hidden; }
1111
</style>
1212
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
1313
</head>
1414
<body>
15-
<nice-pool></nice-pool>
15+
<div id="nicepool-host"></div>
1616
<script src="./nicepool-pyqt5.js"></script>
1717
</body>
1818
</html>

‎integrations/nicepool-pyqt5/src/nicepool_pyqt5/widget.py‎

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from typing import Any
99

1010
import pandas as pd
11-
from PyQt5.QtCore import QUrl, pyqtSignal
11+
from PyQt5.QtCore import QUrl, QUrlQuery, pyqtSignal
1212
from PyQt5.QtWebChannel import QWebChannel
1313
from PyQt5.QtWebEngineWidgets import QWebEngineSettings, QWebEngineView
1414
from PyQt5.QtWidgets import QVBoxLayout, QWidget
@@ -32,8 +32,29 @@ class NicePoolWidget(QWidget):
3232
data_replaced = pyqtSignal()
3333
error_occurred = pyqtSignal(str)
3434

35-
def __init__(self, parent: QWidget | None = None) -> None:
35+
def __init__(
36+
self,
37+
parent: QWidget | None = None,
38+
*,
39+
theme: str = "dark",
40+
controls_collapsed: bool = False,
41+
preset_editing_visible: bool = True,
42+
) -> None:
43+
"""Create a NicePool host with display options applied before first render.
44+
45+
Args:
46+
parent: Optional parent widget.
47+
theme: Initial ``dark`` or ``light`` theme.
48+
controls_collapsed: Whether the controls panel starts collapsed.
49+
preset_editing_visible: Whether preset editing starts visible.
50+
51+
Raises:
52+
ValueError: If ``theme`` is not supported.
53+
RuntimeError: If the packaged browser assets are unavailable.
54+
"""
3655
super().__init__(parent)
56+
if theme not in {"dark", "light"}:
57+
raise ValueError("theme must be 'dark' or 'light'")
3758
static_directory = Path(__file__).resolve().parent / "static"
3859
html_path = static_directory / "index.html"
3960
script_path = static_directory / "nicepool-pyqt5.js"
@@ -51,6 +72,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
5172
self._pending: dict[int, tuple[str, ResultCallback | None, ErrorCallback | None]] = {}
5273

5374
self.web_view = QWebEngineView(self)
75+
self.web_view.hide()
5476
self.web_view.settings().setAttribute(
5577
QWebEngineSettings.JavascriptCanAccessClipboard,
5678
True,
@@ -67,7 +89,18 @@ def __init__(self, parent: QWidget | None = None) -> None:
6789
self._bridge.message_received.connect(self._receive_browser_message)
6890
self._bridge.invalid_message.connect(self.error_occurred)
6991
self.web_view.loadFinished.connect(self._load_finished)
70-
self.web_view.load(QUrl.fromLocalFile(str(html_path)))
92+
page_url = QUrl.fromLocalFile(str(html_path))
93+
query = QUrlQuery()
94+
query.addQueryItem("theme", theme)
95+
query.addQueryItem(
96+
"controlsCollapsed", "true" if controls_collapsed else "false"
97+
)
98+
query.addQueryItem(
99+
"presetEditingVisible",
100+
"true" if preset_editing_visible else "false",
101+
)
102+
page_url.setQuery(query)
103+
self.web_view.load(page_url)
71104

72105
@property
73106
def is_ready(self) -> bool:
@@ -108,6 +141,11 @@ def _receive_browser_message(self, message: dict[str, Any]) -> None:
108141
return
109142
self._ready = True
110143
self.ready_changed.emit(True)
144+
if not any(
145+
message["name"] in {"setData", "initializeData"}
146+
for message in self._queued_messages
147+
):
148+
self.web_view.show()
111149
self._dispatch_next()
112150
return
113151
if kind == "response":
@@ -151,6 +189,7 @@ def _receive_event(self, message: dict[str, Any]) -> None:
151189
elif name == "themeChanged":
152190
self.theme_changed.emit(str(detail))
153191
elif name == "dataReset":
192+
self.web_view.show()
154193
self.data_reset.emit()
155194
elif name == "dataReplaced":
156195
self.data_replaced.emit()
@@ -164,16 +203,28 @@ def set_dataframe(
164203
row_id_column: str,
165204
schema: Sequence[Mapping[str, Any]] | None = None,
166205
pre_filter_columns: Sequence[str] | None = None,
206+
preset_definitions: Sequence[Mapping[str, Any]] | None = None,
207+
default_preset: str | None = None,
167208
) -> None:
168209
"""Atomically replace NicePool data from a pandas DataFrame."""
210+
dataset = dataframe_to_dataset(
211+
dataframe,
212+
row_id_column=row_id_column,
213+
schema=schema,
214+
pre_filter_columns=pre_filter_columns,
215+
)
216+
if preset_definitions is None:
217+
if default_preset is not None:
218+
raise ValueError("default_preset requires preset_definitions")
219+
self._send("setData", dataset)
220+
return
169221
self._send(
170-
"setData",
171-
dataframe_to_dataset(
172-
dataframe,
173-
row_id_column=row_id_column,
174-
schema=schema,
175-
pre_filter_columns=pre_filter_columns,
176-
),
222+
"initializeData",
223+
{
224+
"dataset": dataset,
225+
"presetDefinitions": [dict(item) for item in preset_definitions],
226+
"defaultPresetName": default_preset,
227+
},
177228
)
178229

179230
def set_records(
@@ -183,16 +234,28 @@ def set_records(
183234
row_id_column: str,
184235
schema: Sequence[Mapping[str, Any]] | None = None,
185236
pre_filter_columns: Sequence[str] | None = None,
237+
preset_definitions: Sequence[Mapping[str, Any]] | None = None,
238+
default_preset: str | None = None,
186239
) -> None:
187240
"""Atomically replace NicePool data from rectangular records."""
241+
dataset = records_to_dataset(
242+
records,
243+
row_id_column=row_id_column,
244+
schema=schema,
245+
pre_filter_columns=pre_filter_columns,
246+
)
247+
if preset_definitions is None:
248+
if default_preset is not None:
249+
raise ValueError("default_preset requires preset_definitions")
250+
self._send("setData", dataset)
251+
return
188252
self._send(
189-
"setData",
190-
records_to_dataset(
191-
records,
192-
row_id_column=row_id_column,
193-
schema=schema,
194-
pre_filter_columns=pre_filter_columns,
195-
),
253+
"initializeData",
254+
{
255+
"dataset": dataset,
256+
"presetDefinitions": [dict(item) for item in preset_definitions],
257+
"defaultPresetName": default_preset,
258+
},
196259
)
197260

198261
def replace_dataframe(

‎integrations/nicepool-pyqt5/tests/test_widget.py‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,28 @@
1111
from nicepool_pyqt5 import NicePoolWidget
1212

1313

14+
def test_default_preset_requires_definitions(qtbot) -> None:
15+
"""Reject a default preset when no definitions were supplied."""
16+
widget = NicePoolWidget()
17+
qtbot.addWidget(widget)
18+
with pytest.raises(ValueError, match="requires preset_definitions"):
19+
widget.set_records(
20+
[{"id": "a", "x": 1.0}],
21+
row_id_column="id",
22+
default_preset="Missing",
23+
)
24+
25+
1426
@pytest.mark.skipif(
1527
os.environ.get("NICEPOOL_RUN_WEBENGINE_TEST") != "1",
1628
reason="set NICEPOOL_RUN_WEBENGINE_TEST=1 on a host with a working display/OpenGL context",
1729
)
1830
def test_webengine_bridge_round_trip_and_no_selection_echo(qtbot) -> None:
19-
widget = NicePoolWidget()
31+
widget = NicePoolWidget(
32+
theme="light",
33+
controls_collapsed=True,
34+
preset_editing_visible=False,
35+
)
2036
qtbot.addWidget(widget)
2137
assert widget.web_view.settings().testAttribute(
2238
QWebEngineSettings.JavascriptCanAccessClipboard
@@ -39,6 +55,32 @@ def test_webengine_bridge_round_trip_and_no_selection_echo(qtbot) -> None:
3955
row_id_column="id",
4056
)
4157

58+
initial_display: list[object] = []
59+
widget.web_view.page().runJavaScript(
60+
"""
61+
(() => {
62+
const pool = document.querySelector('nice-pool');
63+
const root = pool?.shadowRoot;
64+
return {
65+
theme: pool?.getTheme(),
66+
controlsCollapsed: pool?.getControlsCollapsed(),
67+
presetEditingVisible: pool?.getShowPresetEditing(),
68+
presetVisible: Boolean(root?.querySelector('.nicepool-preset-bar select')),
69+
};
70+
})();
71+
""",
72+
initial_display.append,
73+
)
74+
qtbot.waitUntil(lambda: len(initial_display) == 1, timeout=5_000)
75+
assert initial_display == [
76+
{
77+
"theme": "light",
78+
"controlsCollapsed": True,
79+
"presetEditingVisible": False,
80+
"presetVisible": True,
81+
}
82+
]
83+
4284
qtbot.wait(250)
4385
widget.set_primary_selection("a")
4486
state: list[object] = []

‎packages/nicepool/e2e/nicepool.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ test('collapses and restores controls through the public element API', async ({
6363
(element as HTMLElement & { setControlsCollapsed(collapsed: boolean): void }).setControlsCollapsed(true)
6464
})
6565
await expect.poll(() => controls.evaluate((element) => element.getBoundingClientRect().width)).toBe(0)
66+
await expect(pool.getByLabel('Preset')).toBeVisible()
6667
await expect.poll(() => pool.evaluate((element) =>
6768
(element as HTMLElement & { getControlsCollapsed(): boolean }).getControlsCollapsed(),
6869
)).toBe(true)

0 commit comments

Comments
 (0)