From af3eb80d24abcf763a43ed2ce21729b6bd7607be Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 24 Jul 2026 10:52:20 -0400 Subject: [PATCH 01/10] feat(ui): add ui.notification API using the browser Notifications API Adds a new ui.notification component that displays a system-level notification via the browser Notifications API. Supports description, icon, tag, silent, on_click, and on_close options. Auto-requests permission and falls back to a toast when notifications are denied or unsupported. --- plugins/ui/docs/components/notification.md | 124 +++++++++++++++ plugins/ui/docs/sidebar.json | 4 + .../src/deephaven/ui/components/__init__.py | 3 +- .../deephaven/ui/components/notification.py | 58 +++++++ .../ui/src/js/src/events/Notification.test.ts | 144 ++++++++++++++++++ plugins/ui/src/js/src/events/Notification.ts | 90 +++++++++++ .../ui/src/js/src/widget/WidgetHandler.tsx | 4 + .../ui/test/deephaven/ui/test_notification.py | 65 ++++++++ 8 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 plugins/ui/docs/components/notification.md create mode 100644 plugins/ui/src/deephaven/ui/components/notification.py create mode 100644 plugins/ui/src/js/src/events/Notification.test.ts create mode 100644 plugins/ui/src/js/src/events/Notification.ts create mode 100644 plugins/ui/test/deephaven/ui/test_notification.py diff --git a/plugins/ui/docs/components/notification.md b/plugins/ui/docs/components/notification.md new file mode 100644 index 000000000..2b1bd9495 --- /dev/null +++ b/plugins/ui/docs/components/notification.md @@ -0,0 +1,124 @@ +# Notification + +Notifications display messages to the user at the operating system level using the browser's [Notifications API](https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API). Unlike toasts, notifications appear outside the browser window, so they can reach the user even when the Deephaven tab is not focused. + +## Example + +```python +from deephaven import ui + +btn = ui.button( + "Show notification", + on_press=lambda: ui.notification("Query complete"), + variant="primary", +) +``` + +## Permissions + +Notifications require the user to grant permission before they can be displayed. The first time `ui.notification` is called, the browser prompts the user to allow notifications. If the user denies permission, or if notifications are not supported (for example, when the page is not served over a secure context such as HTTPS or `localhost`), the message is shown as a [toast](./toast.md) instead. + +## Content + +Notifications are triggered using the method `ui.notification`. The `title` is required, and an optional `description` provides body text below the title. An `icon` may be provided as a URL to an image. + +```python +from deephaven import ui + +btn = ui.button( + "Show notification", + on_press=lambda: ui.notification( + "Download complete", + description="Your file is ready to view.", + icon="https://github.com/deephaven.png", + ), + variant="primary", +) +``` + +## Events + +Notifications support an `on_click` handler that is called when the user clicks the notification, and an `on_close` handler that is called when the notification is dismissed. When `ui.notification` falls back to a toast (for example, when permission is denied), the `on_click` handler is exposed as an action button on the toast so the callback remains reachable. + +```python +from deephaven import ui + +btn = ui.button( + "Show notification", + on_press=lambda: ui.notification( + "An update is available", + description="Click to install the latest version.", + on_click=lambda: print("Clicked!"), + on_close=lambda: print("Closed"), + ), + variant="primary", +) +``` + +## Replacing notifications + +Use the `tag` option to group related notifications. A new notification with the same `tag` as an existing one replaces it instead of stacking, which is useful for updating a notification in place (for example, a progress or status update). + +```python +from deephaven import ui + + +@ui.component +def status_updater(): + def notify(message): + ui.notification(message, tag="job-status") + + return ui.button_group( + ui.button("Start", on_press=lambda: notify("Job started")), + ui.button("Finish", on_press=lambda: notify("Job finished")), + ) + + +my_status_updater = status_updater() +``` + +## Silent notifications + +Set `silent=True` to display a notification without any accompanying sound or vibration, regardless of the device's settings. + +```python +from deephaven import ui + +btn = ui.button( + "Show silent notification", + on_press=lambda: ui.notification("Saved", silent=True), + variant="primary", +) +``` + +## Notification from table example + +This example shows how to create a notification from the latest update of a ticking table. Note that the notification must be triggered on the render thread, whereas the table listener may be fired from another thread. Therefore you must use the render queue to trigger the notification. + +```python order=my_notification_table,_source +from deephaven import time_table +from deephaven import ui + +_source = time_table("PT5S").update("X = i").tail(5) + + +@ui.component +def notification_table(t): + render_queue = ui.use_render_queue() + + def listener_function(update, is_replay): + data_added = update.added()["X"][0] + render_queue(lambda: ui.notification(f"Added {data_added}")) + + ui.use_table_listener(t, listener_function, []) + return t + + +my_notification_table = notification_table(_source) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.notification +``` diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index b9affc191..7cdd77776 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -334,6 +334,10 @@ "label": "multi_select", "path": "components/multi_select.md" }, + { + "label": "notification", + "path": "components/notification.md" + }, { "label": "number_field", "path": "components/number_field.md" diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index 752390750..d1cb9b6e7 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -54,6 +54,7 @@ from .menu_trigger import menu_trigger from .meter import meter from .multi_select import multi_select +from .notification import notification from .number_field import number_field from .panel import panel from .picker import picker @@ -95,7 +96,6 @@ from . import html - __all__ = [ "accordion", "action_button", @@ -152,6 +152,7 @@ "menu_trigger", "meter", "multi_select", + "notification", "number_field", "panel", "picker", diff --git a/plugins/ui/src/deephaven/ui/components/notification.py b/plugins/ui/src/deephaven/ui/components/notification.py new file mode 100644 index 000000000..635f58eab --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/notification.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from ..hooks import use_send_event + +from typing import Callable +from .._internal.utils import dict_to_react_props +from .._internal.EventContext import NoContextException + +_NOTIFICATION_EVENT = "notification.event" + + +class NotificationException(NoContextException): + pass + + +def notification( + title: str, + *, + description: str | None = None, + icon: str | None = None, + tag: str | None = None, + silent: bool | None = None, + on_click: Callable[[], None] | None = None, + on_close: Callable[[], None] | None = None, +) -> None: + """ + Displays a system notification to the user using the browser's Notifications API. + + Notifications appear outside the browser window, at the operating system level, so + they can reach the user even when the Deephaven tab is not focused. The browser must + be served over a secure context (HTTPS or localhost) and the user must grant + permission to display notifications. If permission is denied or notifications are not + supported, the message is shown as a toast instead. + + Args: + title: The title to display in the notification. + description: The body text to display below the title. + icon: The URL of an image to display as the notification's icon. + tag: An identifying tag for the notification. Notifications with the same tag + replace each other instead of stacking, which is useful for updating an + existing notification. + silent: Whether the notification should be silent (no sounds or vibrations), + regardless of the device settings. + on_click: Handler that is called when the user clicks the notification. + on_close: Handler that is called when the notification is closed, either by the + user or after a timeout. + + Returns: + None + """ + params = dict_to_react_props(locals()) + try: + send_event = use_send_event() + except NoContextException as e: + raise NotificationException( + "Notifications must be triggered from the render thread. Use the hook `use_render_queue` to queue a function on the render thread." + ) from e + send_event(_NOTIFICATION_EVENT, params) diff --git a/plugins/ui/src/js/src/events/Notification.test.ts b/plugins/ui/src/js/src/events/Notification.test.ts new file mode 100644 index 000000000..93ae4af95 --- /dev/null +++ b/plugins/ui/src/js/src/events/Notification.test.ts @@ -0,0 +1,144 @@ +import { ToastQueue } from '@deephaven/components'; +import { showNotification } from './Notification'; + +jest.mock('@deephaven/components', () => ({ + ToastQueue: { + info: jest.fn(), + }, +})); + +const mockToastInfo = ToastQueue.info as jest.Mock; + +describe('showNotification', () => { + const originalNotification = (globalThis as { Notification?: unknown }) + .Notification; + + function setNotification(value: unknown): void { + (globalThis as { Notification?: unknown }).Notification = value; + } + + /** + * Create a mock Notification constructor with the given permission and + * requestPermission behavior. + */ + function createNotificationMock({ + permission = 'granted', + requestPermission, + }: { + permission?: NotificationPermission; + requestPermission?: jest.Mock; + } = {}): jest.Mock & { + permission: NotificationPermission; + requestPermission: jest.Mock; + } { + const instances: Array> = []; + const ctor = jest.fn((title: string, options?: NotificationOptions) => { + const instance: Record = { + title, + options, + onclick: null, + onclose: null, + }; + instances.push(instance); + return instance; + }) as jest.Mock & { + permission: NotificationPermission; + requestPermission: jest.Mock; + instances: Array>; + }; + ctor.permission = permission; + ctor.requestPermission = + requestPermission ?? jest.fn().mockResolvedValue(permission); + ctor.instances = instances; + return ctor; + } + + afterEach(() => { + setNotification(originalNotification); + jest.clearAllMocks(); + }); + + it('displays a notification when permission is granted', async () => { + const ctor = createNotificationMock({ permission: 'granted' }); + setNotification(ctor); + + await showNotification({ + title: 'Title', + description: 'Body', + icon: 'icon.png', + tag: 'tag', + silent: true, + }); + + expect(ctor).toHaveBeenCalledWith('Title', { + body: 'Body', + icon: 'icon.png', + tag: 'tag', + silent: true, + }); + expect(mockToastInfo).not.toHaveBeenCalled(); + }); + + it('wires onClick and onClose to the notification', async () => { + const ctor = createNotificationMock({ permission: 'granted' }); + setNotification(ctor); + const onClick = jest.fn(); + const onClose = jest.fn(); + + await showNotification({ title: 'Title', onClick, onClose }); + + const instance = ctor.mock.results[0].value as { + onclick: () => void; + onclose: () => void; + }; + instance.onclick(); + instance.onclose(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('requests permission when not yet determined', async () => { + const requestPermission = jest.fn().mockResolvedValue('granted'); + const ctor = createNotificationMock({ + permission: 'default', + requestPermission, + }); + setNotification(ctor); + + await showNotification({ title: 'Title' }); + + expect(requestPermission).toHaveBeenCalledTimes(1); + expect(ctor).toHaveBeenCalledWith('Title', expect.any(Object)); + }); + + it('falls back to a toast when permission is denied', async () => { + const ctor = createNotificationMock({ permission: 'denied' }); + setNotification(ctor); + + await showNotification({ title: 'Title', description: 'Body' }); + + expect(ctor).not.toHaveBeenCalled(); + expect(mockToastInfo).toHaveBeenCalledWith('Title: Body', undefined); + }); + + it('exposes onClick as a toast action in the fallback', async () => { + const ctor = createNotificationMock({ permission: 'denied' }); + setNotification(ctor); + const onClick = jest.fn(); + + await showNotification({ title: 'Title', onClick }); + + expect(mockToastInfo).toHaveBeenCalledWith('Title', { + actionLabel: 'View', + onAction: onClick, + }); + }); + + it('falls back to a toast when notifications are not supported', async () => { + setNotification(undefined); + + await showNotification({ title: 'Title', description: 'Body' }); + + expect(mockToastInfo).toHaveBeenCalledWith('Title: Body', undefined); + }); +}); diff --git a/plugins/ui/src/js/src/events/Notification.ts b/plugins/ui/src/js/src/events/Notification.ts new file mode 100644 index 000000000..0f595faf7 --- /dev/null +++ b/plugins/ui/src/js/src/events/Notification.ts @@ -0,0 +1,90 @@ +import { ToastQueue } from '@deephaven/components'; +import Log from '@deephaven/log'; + +const log = Log.module('Notification'); + +export const NOTIFICATION_EVENT = 'notification.event'; + +export type NotificationParams = { + title: string; + description?: string; + icon?: string; + tag?: string; + silent?: boolean; + onClick?: () => void; + onClose?: () => void; +}; + +/** + * Show the notification message as a toast. Used as a fallback when the + * Notifications API is unavailable or permission has not been granted. + * + * @param params The notification event parameters + */ +function showToastFallback(params: NotificationParams): void { + const { title, description, onClick } = params; + const message = description != null ? `${title}: ${description}` : title; + ToastQueue.info( + message, + onClick != null ? { actionLabel: 'View', onAction: onClick } : undefined + ); +} + +/** + * Handle a notification event by displaying a system notification using the + * browser's Notifications API. + * + * If notifications are not supported or the user has denied permission, the + * message is shown as a toast instead. If permission has not yet been requested, + * it will be requested before displaying the notification. + * + * @param params The notification event parameters + */ +export async function showNotification( + params: NotificationParams +): Promise { + const { title, description, icon, tag, silent, onClick, onClose } = params; + + if (typeof Notification === 'undefined') { + log.warn('Notifications are not supported, falling back to a toast'); + showToastFallback(params); + return; + } + + let { permission } = Notification; + if (permission === 'default') { + try { + permission = await Notification.requestPermission(); + } catch (e) { + log.warn('Error requesting notification permission', e); + showToastFallback(params); + return; + } + } + + if (permission !== 'granted') { + log.debug('Notification permission not granted, falling back to a toast'); + showToastFallback(params); + return; + } + + const notification = new Notification(title, { + body: description, + icon, + tag, + silent, + }); + + if (onClick != null) { + notification.onclick = () => { + onClick(); + }; + } + if (onClose != null) { + notification.onclose = () => { + onClose(); + }; + } +} + +export default showNotification; diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 326e1483c..0a40b4819 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -54,6 +54,7 @@ import WidgetStatusContext, { } from '../layout/WidgetStatusContext'; import WidgetErrorView from './WidgetErrorView'; import Toast, { TOAST_EVENT } from '../events/Toast'; +import showNotification, { NOTIFICATION_EVENT } from '../events/Notification'; import Navigate, { NAVIGATE_EVENT, type NavigateParams, @@ -468,6 +469,9 @@ function WidgetHandler({ case TOAST_EVENT: Toast(eventParams); break; + case NOTIFICATION_EVENT: + showNotification(eventParams); + break; case NAVIGATE_EVENT: Navigate(eventParams); break; diff --git a/plugins/ui/test/deephaven/ui/test_notification.py b/plugins/ui/test/deephaven/ui/test_notification.py new file mode 100644 index 000000000..6a88ec19a --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_notification.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from unittest.mock import Mock + +from .BaseTest import BaseTestCase +from deephaven.ui._internal.EventContext import EventContext + + +class NotificationTestCase(BaseTestCase): + """Tests for the ui.notification API.""" + + def test_sends_notification_event(self): + from deephaven.ui.components.notification import notification + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + notification("Hello") + + send_event_mock.assert_called_once() + name, payload = send_event_mock.call_args[0] + self.assertEqual(name, "notification.event") + self.assertEqual(payload["title"], "Hello") + # None-valued options should be removed + self.assertNotIn("description", payload) + self.assertNotIn("icon", payload) + self.assertNotIn("onClick", payload) + + def test_converts_options_to_camel_case(self): + from deephaven.ui.components.notification import notification + + on_click = lambda: None + on_close = lambda: None + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + notification( + "Title", + description="Body text", + icon="https://example.com/icon.png", + tag="my-tag", + silent=True, + on_click=on_click, + on_close=on_close, + ) + + send_event_mock.assert_called_once() + name, payload = send_event_mock.call_args[0] + self.assertEqual(name, "notification.event") + self.assertEqual(payload["title"], "Title") + self.assertEqual(payload["description"], "Body text") + self.assertEqual(payload["icon"], "https://example.com/icon.png") + self.assertEqual(payload["tag"], "my-tag") + self.assertEqual(payload["silent"], True) + self.assertIs(payload["onClick"], on_click) + self.assertIs(payload["onClose"], on_close) + + def test_raises_outside_render_thread(self): + from deephaven.ui.components.notification import ( + notification, + NotificationException, + ) + + with self.assertRaises(NotificationException): + notification("Hello") From 37a11411f9dcaef0ade30b913f6f561e8928ce30 Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 24 Jul 2026 14:25:47 -0400 Subject: [PATCH 02/10] feat(ui): add ui.tone for playing tones and jingles Add a ui.tone API that plays notes, chords, and sequences using the browser's Web Audio API, synthesized natively from an oscillator so no audio file is transferred. Supports note names or frequencies, per-note durations, chords (nested lists), rests (None), waveform selection, and gain, following the same event-plumbing pattern as ui.notification. Includes Python and JS unit tests and component docs. --- plugins/ui/docs/components/tone.md | 172 +++++++++++++++ plugins/ui/docs/sidebar.json | 4 + .../src/deephaven/ui/components/__init__.py | 2 + .../ui/src/deephaven/ui/components/tone.py | 201 ++++++++++++++++++ plugins/ui/src/js/src/events/Tone.test.ts | 199 +++++++++++++++++ plugins/ui/src/js/src/events/Tone.ts | 165 ++++++++++++++ .../ui/src/js/src/widget/WidgetHandler.tsx | 4 + plugins/ui/test/deephaven/ui/test_tone.py | 169 +++++++++++++++ 8 files changed, 916 insertions(+) create mode 100644 plugins/ui/docs/components/tone.md create mode 100644 plugins/ui/src/deephaven/ui/components/tone.py create mode 100644 plugins/ui/src/js/src/events/Tone.test.ts create mode 100644 plugins/ui/src/js/src/events/Tone.ts create mode 100644 plugins/ui/test/deephaven/ui/test_tone.py diff --git a/plugins/ui/docs/components/tone.md b/plugins/ui/docs/components/tone.md new file mode 100644 index 000000000..03ce4c5d1 --- /dev/null +++ b/plugins/ui/docs/components/tone.md @@ -0,0 +1,172 @@ +# Tone + +Tones play short sounds to the user using the browser's [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API). The sound is synthesized natively in the browser from an oscillator, so no audio file needs to be transferred. Use tones to provide quick audio feedback, such as signaling that a long-running task has finished or that new data has arrived. + +## Example + +```python +from deephaven import ui + +btn = ui.button( + "Play tone", + on_press=lambda: ui.tone("C5"), + variant="primary", +) +``` + +## Notes + +Tones are triggered using the method `ui.tone`. A note can be specified either by name (such as `"C4"`, `"F#3"`, or `"Bb5"`) or as a frequency in Hertz (such as `440`). Note names use scientific pitch notation, where `A4` is 440 Hz. + +```python +from deephaven import ui + + +@ui.component +def note_buttons(): + return ui.button_group( + ui.button("Note name", on_press=lambda: ui.tone("A4")), + ui.button("Frequency", on_press=lambda: ui.tone(440)), + ) + + +my_note_buttons = note_buttons() +``` + +## Rests + +Insert a pause into a sequence with `None`. A rest produces no sound but still +takes up its `duration`, so you can control the spacing between phrases +independently of the uniform `gap`. Give a rest its own length with a +`(None, duration)` tuple. + +```python +from deephaven import ui + +btn = ui.button( + "Play with a pause", + on_press=lambda: ui.tone( + ["C5", (None, 0.4), "C5"], + duration=0.15, + ), + variant="primary", +) +``` + +## Sequences + +To play a melody, pass a list of notes. Each note plays in turn, separated by a short `gap`. By default every note uses the same `duration`, but you can give a note its own duration by passing a `(note, duration)` tuple. Durations and gaps are measured in seconds. + +```python +from deephaven import ui + +btn = ui.button( + "Play scale", + on_press=lambda: ui.tone( + ["C4", "D4", "E4", "F4", "G4", "A4", "B4", ("C5", 0.5)], + duration=0.15, + ), + variant="primary", +) +``` + +## Chords + +To play notes simultaneously, group them in a nested list. Each nested list is a chord whose notes sound together. You can mix single notes and chords in the same sequence, and a chord can be given its own duration with a `(chord, duration)` tuple. + +```python +from deephaven import ui + +btn = ui.button( + "Play chords", + on_press=lambda: ui.tone( + [ + ["C4", "E4", "G4"], + ["F4", "A4", "C5"], + (["G4", "B4", "D5"], 0.6), + ], + duration=0.4, + ), + variant="primary", +) +``` + +## Waveform and volume + +The `waveform` option selects the oscillator shape: `"sine"` (the default), `"square"`, `"triangle"`, or `"sawtooth"`. The `gain` option sets the volume from `0` (silent) to `1` (loudest). + +```python +from deephaven import ui + +btn = ui.button( + "Play buzzer", + on_press=lambda: ui.tone("A3", waveform="sawtooth", gain=0.3), + variant="primary", +) +``` + +## Playing a jingle + +Combining chords, rests, and per-note durations lets you play a short jingle. +This example recreates the Deephaven outro sting: a single strum of an E major +chord, a pause, and then the same chord strummed several times to finish. + +```python +from deephaven import ui + +_CHORD = ["E4", "G#4", "B4", "E5"] + +btn = ui.button( + "Play jingle", + on_press=lambda: ui.tone( + [ + (_CHORD, 0.35), + (None, 0.55), + (_CHORD, 0.12), + (_CHORD, 0.12), + (_CHORD, 0.12), + (_CHORD, 0.12), + (_CHORD, 0.3), + ], + gap=0.06, + waveform="triangle", + gain=0.6, + ), + variant="primary", +) +``` + +## Autoplay restrictions + +Browsers block audio until the user has interacted with the page. Playing a tone in response to a user action, such as pressing a button, works reliably. A tone triggered without a prior interaction, such as from a ticking table before the user has clicked anything, may not be audible until the user interacts with the page. + +## Tone from table example + +This example plays a tone from the latest update of a ticking table. Note that the tone must be triggered on the render thread, whereas the table listener may be fired from another thread. Therefore you must use the render queue to trigger the tone. + +```python order=my_tone_table,_source +from deephaven import time_table +from deephaven import ui + +_source = time_table("PT2S").update("X = i").tail(5) + + +@ui.component +def tone_table(t): + render_queue = ui.use_render_queue() + + def listener_function(update, is_replay): + render_queue(lambda: ui.tone("C5")) + + ui.use_table_listener(t, listener_function, []) + return t + + +my_tone_table = tone_table(_source) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.ui.tone +``` diff --git a/plugins/ui/docs/sidebar.json b/plugins/ui/docs/sidebar.json index 7cdd77776..a7dfd24ae 100644 --- a/plugins/ui/docs/sidebar.json +++ b/plugins/ui/docs/sidebar.json @@ -422,6 +422,10 @@ "label": "toggle_button", "path": "components/toggle_button.md" }, + { + "label": "tone", + "path": "components/tone.md" + }, { "label": "URI/resolve", "path": "components/uri.md" diff --git a/plugins/ui/src/deephaven/ui/components/__init__.py b/plugins/ui/src/deephaven/ui/components/__init__.py index d1cb9b6e7..44e58c37d 100644 --- a/plugins/ui/src/deephaven/ui/components/__init__.py +++ b/plugins/ui/src/deephaven/ui/components/__init__.py @@ -90,6 +90,7 @@ from .time_field import time_field from .toast import toast from .toggle_button import toggle_button +from .tone import tone from .view import view from .route import route from .router import router @@ -188,5 +189,6 @@ "time_field", "toast", "toggle_button", + "tone", "view", ] diff --git a/plugins/ui/src/deephaven/ui/components/tone.py b/plugins/ui/src/deephaven/ui/components/tone.py new file mode 100644 index 000000000..e576a6892 --- /dev/null +++ b/plugins/ui/src/deephaven/ui/components/tone.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import re + +from ..hooks import use_send_event + +from typing import Sequence, Union, cast +from .._internal.utils import dict_to_react_props +from .._internal.EventContext import NoContextException + +_TONE_EVENT = "tone.event" + +_WAVEFORMS = ("sine", "square", "triangle", "sawtooth") + +# A single pitch: a note name like "C4" / "F#3" / "Bb5", or a frequency in Hertz. +Pitch = Union[str, float] + +# One step of a tone sequence. It may be: +# * a single pitch: "C4" or 440 +# * a chord (list of pitches played together): ["C4", "E4", "G4"] +# * a rest (silence for the step's duration): None +# * a (pitch_or_chord_or_rest, duration) tuple: ("C4", 0.4), (["C4", "E4"], 0.4), +# or (None, 0.4) +Note = Union[ + Pitch, + Sequence[Pitch], + None, + "tuple[Union[Pitch, Sequence[Pitch], None], float]", +] + +# The value accepted by `tone`: either a single step, or a sequence of steps. +Notes = Union[Note, Sequence[Note]] + +# Match a note name like "C4", "c#-1", "Bb10". Letter A-G, optional accidental +# (# or b), then an integer octave (may be negative). +_NOTE_NAME_RE = re.compile(r"^[A-Ga-g][#b]?-?\d+$") + + +class ToneException(NoContextException): + pass + + +def _normalize_pitch(pitch: Pitch) -> str | float: + """ + Validate a single pitch and return it in wire form. + + Args: + pitch: A note name (e.g. "C4") or a frequency in Hertz. + + Returns: + The validated pitch, unchanged. + """ + if isinstance(pitch, bool): + # bool is a subclass of int; reject it explicitly so `True`/`False` + # are not silently treated as frequencies. + raise ToneException(f"Invalid pitch: {pitch!r}") + if isinstance(pitch, (int, float)): + if pitch <= 0: + raise ToneException(f"Frequency must be positive, got {pitch}") + return pitch + if isinstance(pitch, str) and _NOTE_NAME_RE.match(pitch): + return pitch + raise ToneException( + f"Invalid pitch {pitch!r}. Use a note name like 'C4' or a positive " + f"frequency in Hertz." + ) + + +def _normalize_step(step: Note, default_duration: float) -> dict: + """ + Normalize a single step into wire form: a dict with a list of pitches and a + duration. + + Args: + step: A pitch, a chord (list of pitches), a rest (``None``), or a + (pitch_or_chord_or_rest, duration) tuple. + default_duration: The duration to use when the step does not specify one. + + Returns: + A dict of the form ``{"notes": [pitch, ...], "duration": seconds}``. A + rest is represented by an empty ``notes`` list. + """ + duration = default_duration + value: Note = step + + # A tuple is a (pitch_or_chord_or_rest, duration) pair. + if isinstance(step, tuple): + if len(step) != 2: + raise ToneException( + f"A (note, duration) step must have exactly 2 elements, got {step!r}" + ) + value, duration = step + if isinstance(duration, bool) or not isinstance(duration, (int, float)): + raise ToneException(f"Duration must be a number, got {duration!r}") + if duration <= 0: + raise ToneException(f"Duration must be positive, got {duration}") + + # None is a rest: silence for the step's duration. + if value is None: + return {"notes": [], "duration": float(duration)} + + # A list is a chord (pitches played simultaneously); anything else is a + # single pitch. + if isinstance(value, list): + if len(value) == 0: + raise ToneException("A chord must contain at least one note") + pitches = [_normalize_pitch(p) for p in value] + else: + pitches = [_normalize_pitch(cast(Pitch, value))] + + return {"notes": pitches, "duration": float(duration)} + + +def _normalize_notes(notes: Notes, default_duration: float) -> list[dict]: + """ + Normalize the ``notes`` argument into a list of wire-form steps. + + A top-level ``list`` is treated as a sequence of steps, where each element is + a single note, a chord (a nested list), or a (note, duration) tuple. Any + other value is treated as a single step. + + Args: + notes: The notes to play. + default_duration: The duration to use for steps without an explicit one. + + Returns: + A list of wire-form step dicts. + """ + steps: Sequence[Note] = notes if isinstance(notes, list) else [notes] # type: ignore[list-item] + if len(steps) == 0: + raise ToneException("`notes` must contain at least one note") + return [_normalize_step(step, default_duration) for step in steps] + + +def tone( + notes: Notes, + *, + duration: float = 0.2, + gap: float = 0.05, + waveform: str = "sine", + gain: float = 0.5, +) -> None: + """ + Plays one or more tones to the user using the browser's Web Audio API. + + Tones are synthesized natively in the browser from an oscillator, so no audio + file is transferred. Provide a single note, a chord, or a sequence of notes to + play a short melody or jingle. + + Args: + notes: The note or notes to play. This may be: + + * A single note: a note name like ``"C4"`` or a frequency in Hertz + like ``440``. + * A chord: a list of notes played simultaneously, like + ``["C4", "E4", "G4"]``. + * A rest: ``None`` plays silence for the step's duration, which is + useful for adding a pause between notes in a sequence. + * A sequence: a list whose elements are single notes, chords (nested + lists), rests (``None``), or ``(note, duration)`` tuples. For + example ``["C4", ["E4", "G4"], (None, 0.5), ("C5", 0.5)]`` plays a + note, then a chord, then a half-second rest, then a note held for + half a second. + duration: The default duration in seconds for a note that does not + specify its own duration. + gap: The silence in seconds inserted between successive notes in a + sequence. + waveform: The oscillator waveform to use. One of ``"sine"``, ``"square"``, + ``"triangle"``, or ``"sawtooth"``. + gain: The output volume, from ``0`` (silent) to ``1`` (loudest). + + Returns: + None + """ + if waveform not in _WAVEFORMS: + raise ToneException( + f"Invalid waveform {waveform!r}. Must be one of {', '.join(_WAVEFORMS)}." + ) + if duration <= 0: + raise ToneException(f"duration must be positive, got {duration}") + if gap < 0: + raise ToneException(f"gap must be non-negative, got {gap}") + if not 0 <= gain <= 1: + raise ToneException(f"gain must be between 0 and 1, got {gain}") + + params = dict_to_react_props( + { + "notes": _normalize_notes(notes, duration), + "gap": gap, + "waveform": waveform, + "gain": gain, + } + ) + + try: + send_event = use_send_event() + except NoContextException as e: + raise ToneException( + "Tones must be triggered from the render thread. Use the hook `use_render_queue` to queue a function on the render thread." + ) from e + send_event(_TONE_EVENT, params) diff --git a/plugins/ui/src/js/src/events/Tone.test.ts b/plugins/ui/src/js/src/events/Tone.test.ts new file mode 100644 index 000000000..f59a246d1 --- /dev/null +++ b/plugins/ui/src/js/src/events/Tone.test.ts @@ -0,0 +1,199 @@ +import { noteNameToFrequency, pitchToFrequency, type ToneParams } from './Tone'; + +type MockOscillator = { + type: OscillatorType; + frequency: { setValueAtTime: jest.Mock }; + connect: jest.Mock; + start: jest.Mock; + stop: jest.Mock; +}; + +type MockGain = { + gain: { + setValueAtTime: jest.Mock; + linearRampToValueAtTime: jest.Mock; + }; + connect: jest.Mock; +}; + +describe('noteNameToFrequency', () => { + it.each([ + ['A4', 440], + ['C4', 261.63], + ['E4', 329.63], + ['G#4', 415.3], + ['Bb4', 466.16], + ['A5', 880], + ['A3', 220], + ])('converts %s to ~%f Hz', (name, expected) => { + expect(noteNameToFrequency(name)).toBeCloseTo(expected, 1); + }); + + it('throws on an invalid note name', () => { + expect(() => noteNameToFrequency('H4')).toThrow(); + }); +}); + +describe('pitchToFrequency', () => { + it('passes numbers through as frequencies', () => { + expect(pitchToFrequency(523.25)).toBe(523.25); + }); + + it('converts note names', () => { + expect(pitchToFrequency('A4')).toBeCloseTo(440, 1); + }); +}); + +describe('playTone', () => { + let oscillators: MockOscillator[]; + let gains: MockGain[]; + let mockContext: { + currentTime: number; + state: AudioContextState; + destination: unknown; + createOscillator: jest.Mock; + createGain: jest.Mock; + resume: jest.Mock; + }; + + const originalAudioContext = window.AudioContext; + + // playTone caches a module-level AudioContext singleton, so reset the module + // registry before each test to get a fresh context and avoid leaking state + // (such as the context's `state`) between tests. + let playTone: typeof import('./Tone').playTone; + + beforeEach(() => { + jest.resetModules(); + oscillators = []; + gains = []; + mockContext = { + currentTime: 0, + state: 'running', + destination: {}, + createOscillator: jest.fn(() => { + const osc: MockOscillator = { + type: 'sine', + frequency: { setValueAtTime: jest.fn() }, + connect: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + }; + oscillators.push(osc); + return osc; + }), + createGain: jest.fn(() => { + const gain: MockGain = { + gain: { + setValueAtTime: jest.fn(), + linearRampToValueAtTime: jest.fn(), + }, + connect: jest.fn(), + }; + gains.push(gain); + return gain; + }), + resume: jest.fn().mockResolvedValue(undefined), + }; + (window as { AudioContext: unknown }).AudioContext = jest.fn( + () => mockContext + ); + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + playTone = require('./Tone').playTone; + }); + + afterEach(() => { + (window as { AudioContext: unknown }).AudioContext = originalAudioContext; + jest.clearAllMocks(); + }); + + function play(params: Partial): void { + playTone({ + notes: [], + gap: 0.05, + waveform: 'sine', + gain: 0.5, + ...params, + }); + } + + it('creates an oscillator for a single note', () => { + play({ notes: [{ notes: ['A4'], duration: 0.2 }] }); + + expect(oscillators).toHaveLength(1); + expect(oscillators[0].type).toBe('sine'); + expect(oscillators[0].frequency.setValueAtTime).toHaveBeenCalledWith( + expect.closeTo(440, 1), + 0 + ); + expect(oscillators[0].start).toHaveBeenCalledWith(0); + expect(oscillators[0].stop).toHaveBeenCalledWith(0.2); + }); + + it('schedules a sequence of notes back to back with a gap', () => { + play({ + notes: [ + { notes: ['A4'], duration: 0.2 }, + { notes: ['C4'], duration: 0.3 }, + ], + gap: 0.1, + }); + + expect(oscillators).toHaveLength(2); + expect(oscillators[0].start).toHaveBeenCalledWith(0); + expect(oscillators[0].stop).toHaveBeenCalledWith(0.2); + // Second note starts after the first note's duration plus the gap. + expect(oscillators[1].start).toHaveBeenCalledWith(expect.closeTo(0.3, 5)); + expect(oscillators[1].stop).toHaveBeenCalledWith(expect.closeTo(0.6, 5)); + }); + + it('plays chord notes simultaneously with scaled gain', () => { + play({ + notes: [{ notes: ['C4', 'E4', 'G4'], duration: 0.4 }], + gain: 0.6, + }); + + expect(oscillators).toHaveLength(3); + // All chord notes share the same start and stop time. + oscillators.forEach(osc => { + expect(osc.start).toHaveBeenCalledWith(0); + expect(osc.stop).toHaveBeenCalledWith(0.4); + }); + // Gain is scaled by 1/sqrt(3) so the summed chord does not clip. + const expectedPeak = 0.6 / Math.sqrt(3); + gains.forEach(gain => { + expect(gain.gain.linearRampToValueAtTime).toHaveBeenCalledWith( + expect.closeTo(expectedPeak, 5), + expect.any(Number) + ); + }); + }); + + it('uses the requested waveform', () => { + play({ notes: [{ notes: ['A4'], duration: 0.2 }], waveform: 'square' }); + expect(oscillators[0].type).toBe('square'); + }); + + it('treats a step with no notes as a silent rest that advances the schedule', () => { + play({ + notes: [ + { notes: ['A4'], duration: 0.2 }, + { notes: [], duration: 0.5 }, + { notes: ['C4'], duration: 0.2 }, + ], + gap: 0.1, + }); + + // The rest produces no oscillator, but the following note is delayed by the + // rest's duration plus the surrounding gaps. + expect(oscillators).toHaveLength(2); + expect(oscillators[1].start).toHaveBeenCalledWith(expect.closeTo(0.9, 5)); + expect(oscillators[1].stop).toHaveBeenCalledWith(expect.closeTo(1.1, 5)); + }); + + it('resumes a suspended audio context', () => { + mockContext.state = 'suspended'; + play({ notes: [{ notes: ['A4'], duration: 0.2 }] }); + expect(mockContext.resume).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/ui/src/js/src/events/Tone.ts b/plugins/ui/src/js/src/events/Tone.ts new file mode 100644 index 000000000..9fad4d887 --- /dev/null +++ b/plugins/ui/src/js/src/events/Tone.ts @@ -0,0 +1,165 @@ +import Log from '@deephaven/log'; + +const log = Log.module('Tone'); + +export const TONE_EVENT = 'tone.event'; + +/** A pitch is either a note name like "C4" or a frequency in Hertz. */ +export type Pitch = string | number; + +/** One step of a tone sequence: a set of pitches played together for a duration. */ +export type ToneStep = { + notes: Pitch[]; + duration: number; +}; + +export type ToneParams = { + notes: ToneStep[]; + gap: number; + waveform: OscillatorType; + gain: number; +}; + +/** Semitone offsets from C within an octave, keyed by note letter. */ +const NOTE_OFFSETS: Record = { + C: 0, + D: 2, + E: 4, + F: 5, + G: 7, + A: 9, + B: 11, +}; + +const NOTE_NAME_RE = /^([A-Ga-g])([#b]?)(-?\d+)$/; + +/** + * Convert a note name like "C4", "F#3", or "Bb5" to its frequency in Hertz + * using equal temperament with A4 = 440 Hz. + * + * @param name The note name to convert + * @returns The frequency in Hertz + */ +export function noteNameToFrequency(name: string): number { + const match = NOTE_NAME_RE.exec(name); + if (match == null) { + throw new Error(`Invalid note name: ${name}`); + } + const [, letter, accidental, octaveStr] = match; + let semitone = NOTE_OFFSETS[letter.toUpperCase()]; + if (accidental === '#') { + semitone += 1; + } else if (accidental === 'b') { + semitone -= 1; + } + const octave = parseInt(octaveStr, 10); + // MIDI note number, where C-1 = 0 and A4 = 69. + const midi = semitone + (octave + 1) * 12; + return 440 * 2 ** ((midi - 69) / 12); +} + +/** + * Resolve a pitch (note name or frequency) to a frequency in Hertz. + * + * @param pitch The pitch to resolve + * @returns The frequency in Hertz + */ +export function pitchToFrequency(pitch: Pitch): number { + return typeof pitch === 'number' ? pitch : noteNameToFrequency(pitch); +} + +let audioContext: AudioContext | null = null; + +/** + * Get the shared AudioContext, creating it lazily on first use. Returns null if + * the Web Audio API is not available. + */ +function getAudioContext(): AudioContext | null { + if (audioContext == null) { + const Ctor = + window.AudioContext ?? + (window as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (Ctor == null) { + return null; + } + audioContext = new Ctor(); + } + return audioContext; +} + +// Duration in seconds of the volume ramp applied at the start and end of each +// note to avoid audible clicks. +const RAMP_TIME = 0.008; + +/** + * Handle a tone event by playing the requested notes using the Web Audio API. + * + * Notes are scheduled sequentially, each starting after the previous note's + * duration plus the configured gap. The pitches within a single step are played + * simultaneously as a chord. A step with no pitches is a rest: it produces no + * sound but still advances the schedule by its duration. + * + * @param params The tone event parameters + */ +export function playTone(params: ToneParams): void { + const { notes, gap, waveform, gain } = params; + + const ctx = getAudioContext(); + if (ctx == null) { + log.warn('Web Audio API is not supported; cannot play tone'); + return; + } + + // Browsers start the AudioContext suspended until a user gesture; resume it so + // tones triggered from an event handler are audible. + if (ctx.state === 'suspended') { + ctx.resume().catch(e => { + log.warn('Unable to resume audio context', e); + }); + } + + let startTime = ctx.currentTime; + notes.forEach(step => { + const { duration } = step; + const endTime = startTime + duration; + // Scale the gain so summing simultaneous oscillators does not clip. + const stepGain = gain / Math.sqrt(Math.max(step.notes.length, 1)); + + step.notes.forEach(pitch => { + let frequency: number; + try { + frequency = pitchToFrequency(pitch); + } catch (e) { + log.warn('Skipping invalid pitch', pitch, e); + return; + } + + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + oscillator.type = waveform; + oscillator.frequency.setValueAtTime(frequency, startTime); + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + // Ramp the gain up and down to avoid clicks at the note boundaries. + gainNode.gain.setValueAtTime(0, startTime); + gainNode.gain.linearRampToValueAtTime( + stepGain, + startTime + Math.min(RAMP_TIME, duration / 2) + ); + gainNode.gain.setValueAtTime( + stepGain, + Math.max(startTime, endTime - RAMP_TIME) + ); + gainNode.gain.linearRampToValueAtTime(0, endTime); + + oscillator.start(startTime); + oscillator.stop(endTime); + }); + + startTime = endTime + gap; + }); +} + +export default playTone; diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index 0a40b4819..b22d4357c 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -55,6 +55,7 @@ import WidgetStatusContext, { import WidgetErrorView from './WidgetErrorView'; import Toast, { TOAST_EVENT } from '../events/Toast'; import showNotification, { NOTIFICATION_EVENT } from '../events/Notification'; +import playTone, { TONE_EVENT } from '../events/Tone'; import Navigate, { NAVIGATE_EVENT, type NavigateParams, @@ -472,6 +473,9 @@ function WidgetHandler({ case NOTIFICATION_EVENT: showNotification(eventParams); break; + case TONE_EVENT: + playTone(eventParams); + break; case NAVIGATE_EVENT: Navigate(eventParams); break; diff --git a/plugins/ui/test/deephaven/ui/test_tone.py b/plugins/ui/test/deephaven/ui/test_tone.py new file mode 100644 index 000000000..a44cb5bc1 --- /dev/null +++ b/plugins/ui/test/deephaven/ui/test_tone.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from unittest.mock import Mock + +from .BaseTest import BaseTestCase +from deephaven.ui._internal.EventContext import EventContext + + +class ToneTestCase(BaseTestCase): + """Tests for the ui.tone API.""" + + def test_sends_tone_event(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone("C4") + + send_event_mock.assert_called_once() + name, payload = send_event_mock.call_args[0] + self.assertEqual(name, "tone.event") + self.assertEqual(payload["notes"], [{"notes": ["C4"], "duration": 0.2}]) + self.assertEqual(payload["gap"], 0.05) + self.assertEqual(payload["waveform"], "sine") + self.assertEqual(payload["gain"], 0.5) + + def test_accepts_frequency(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone(440) + + _, payload = send_event_mock.call_args[0] + self.assertEqual(payload["notes"], [{"notes": [440], "duration": 0.2}]) + + def test_normalizes_sequence(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone(["C4", "E4", "G4"], duration=0.3) + + _, payload = send_event_mock.call_args[0] + self.assertEqual( + payload["notes"], + [ + {"notes": ["C4"], "duration": 0.3}, + {"notes": ["E4"], "duration": 0.3}, + {"notes": ["G4"], "duration": 0.3}, + ], + ) + + def test_normalizes_chord(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone([["C4", "E4", "G4"]]) + + _, payload = send_event_mock.call_args[0] + self.assertEqual( + payload["notes"], [{"notes": ["C4", "E4", "G4"], "duration": 0.2}] + ) + + def test_normalizes_per_note_duration(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone(["C4", ("E4", 0.5), (["G4", "B4"], 0.75)]) + + _, payload = send_event_mock.call_args[0] + self.assertEqual( + payload["notes"], + [ + {"notes": ["C4"], "duration": 0.2}, + {"notes": ["E4"], "duration": 0.5}, + {"notes": ["G4", "B4"], "duration": 0.75}, + ], + ) + + def test_passes_options(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone("C4", gap=0.1, waveform="square", gain=0.25) + + _, payload = send_event_mock.call_args[0] + self.assertEqual(payload["gap"], 0.1) + self.assertEqual(payload["waveform"], "square") + self.assertEqual(payload["gain"], 0.25) + + def test_normalizes_rest(self): + from deephaven.ui.components.tone import tone + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + tone(["C4", None, ("E4", 0.5), (None, 0.75)]) + + _, payload = send_event_mock.call_args[0] + self.assertEqual( + payload["notes"], + [ + {"notes": ["C4"], "duration": 0.2}, + {"notes": [], "duration": 0.2}, + {"notes": ["E4"], "duration": 0.5}, + {"notes": [], "duration": 0.75}, + ], + ) + + def test_rejects_invalid_waveform(self): + from deephaven.ui.components.tone import tone, ToneException + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + with self.assertRaises(ToneException): + tone("C4", waveform="triangle-wave") + + def test_rejects_invalid_gain(self): + from deephaven.ui.components.tone import tone, ToneException + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + with self.assertRaises(ToneException): + tone("C4", gain=2) + + def test_rejects_invalid_note_name(self): + from deephaven.ui.components.tone import tone, ToneException + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + with self.assertRaises(ToneException): + tone("H4") + + def test_rejects_non_positive_frequency(self): + from deephaven.ui.components.tone import tone, ToneException + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + with self.assertRaises(ToneException): + tone(0) + + def test_rejects_empty_chord(self): + from deephaven.ui.components.tone import tone, ToneException + + send_event_mock = Mock() + ec = EventContext(send_event_mock) + with ec.open(): + with self.assertRaises(ToneException): + tone([[]]) + + def test_raises_outside_render_thread(self): + from deephaven.ui.components.tone import tone, ToneException + + with self.assertRaises(ToneException): + tone("C4") From 5d973cc1693e32195b74c694d1fddb0dbdb9791e Mon Sep 17 00:00:00 2001 From: mikebender Date: Mon, 27 Jul 2026 17:02:13 -0400 Subject: [PATCH 03/10] Wire up EventPlugin framework - Update the ElementPlugin to support an eventMapping as well for handling events --- plugins/ui/src/js/src/events/EventPlugin.ts | 39 ++++++++++ .../js/src/events/usePluginsEventMap.test.ts | 51 +++++++++++++ .../src/js/src/events/usePluginsEventMap.ts | 44 +++++++++++ plugins/ui/src/js/src/index.ts | 7 ++ .../src/js/src/widget/WidgetHandler.test.tsx | 71 ++++++++++++++++++ .../ui/src/js/src/widget/WidgetHandler.tsx | 34 ++++----- plugins/ui/src/js/src/widget/WidgetUtils.tsx | 39 ++++++++++ templates/README.md | 4 +- templates/element/cookiecutter.json | 3 +- .../README.md | 73 +++++++++++++++---- .../src/{{ cookiecutter.__js_plugin_obj }}.ts | 23 +++++- .../{{ cookiecutter.__component_name }}.py | 24 +++++- 12 files changed, 375 insertions(+), 37 deletions(-) create mode 100644 plugins/ui/src/js/src/events/EventPlugin.ts create mode 100644 plugins/ui/src/js/src/events/usePluginsEventMap.test.ts create mode 100644 plugins/ui/src/js/src/events/usePluginsEventMap.ts diff --git a/plugins/ui/src/js/src/events/EventPlugin.ts b/plugins/ui/src/js/src/events/EventPlugin.ts new file mode 100644 index 000000000..ab084477e --- /dev/null +++ b/plugins/ui/src/js/src/events/EventPlugin.ts @@ -0,0 +1,39 @@ +import { + type ElementPlugin, + isElementPlugin, + type PluginModuleExport, +} from '@deephaven/plugin'; + +/** + * A handler for an event sent from deephaven.ui via `use_send_event`. + * The params are the JSON-decoded payload of the event, with any callables + * re-hydrated into callable functions. + */ +export type UIEventHandler = (params: Record) => void; + +/** A mapping of event names to their handlers. */ +export type UIEventMapping = Record; + +/** + * An event plugin is an {@link ElementPlugin} that additionally handles custom + * events sent from deephaven.ui via `use_send_event`. The `eventMapping` + * contains the event names as keys and the handlers as values. + * + * Because an event plugin is also an element plugin, the `mapping` property is + * still required. If the plugin only handles events and does not render any + * elements, set `mapping` to an empty object. + */ +export interface EventPlugin extends ElementPlugin { + eventMapping: UIEventMapping; +} + +/** Type guard to check if the given plugin is an {@link EventPlugin}. */ +export function isEventPlugin( + plugin: PluginModuleExport +): plugin is EventPlugin { + return ( + isElementPlugin(plugin) && + 'eventMapping' in plugin && + (plugin as Partial).eventMapping != null + ); +} diff --git a/plugins/ui/src/js/src/events/usePluginsEventMap.test.ts b/plugins/ui/src/js/src/events/usePluginsEventMap.test.ts new file mode 100644 index 000000000..bfab75da1 --- /dev/null +++ b/plugins/ui/src/js/src/events/usePluginsEventMap.test.ts @@ -0,0 +1,51 @@ +import { type PluginModuleMap } from '@deephaven/plugin'; +import { getPluginsEventMap } from './usePluginsEventMap'; + +function makeEventPlugin( + name: string, + eventMapping: Record) => void> +): [string, unknown] { + return [name, { name, type: 'ElementPlugin', mapping: {}, eventMapping }]; +} + +function makeElementPlugin(name: string): [string, unknown] { + return [name, { name, type: 'ElementPlugin', mapping: {} }]; +} + +it('extracts event handlers from event plugins', () => { + const handlerA = jest.fn(); + const handlerB = jest.fn(); + const plugins = new Map([ + makeEventPlugin('plugin-a', { 'a.event': handlerA }), + makeElementPlugin('plugin-element'), + makeEventPlugin('plugin-b', { 'b.event': handlerB }), + ]) as unknown as PluginModuleMap; + + const eventMap = getPluginsEventMap(plugins); + + expect(eventMap.size).toBe(2); + expect(eventMap.get('a.event')).toBe(handlerA); + expect(eventMap.get('b.event')).toBe(handlerB); +}); + +it('returns an empty map when there are no event plugins', () => { + const plugins = new Map([ + makeElementPlugin('plugin-element'), + ]) as unknown as PluginModuleMap; + + expect(getPluginsEventMap(plugins).size).toBe(0); +}); + +it('uses the last registered handler and warns on duplicate event names', () => { + const first = jest.fn(); + const second = jest.fn(); + const plugins = new Map([ + makeEventPlugin('plugin-a', { 'dup.event': first }), + makeEventPlugin('plugin-b', { 'dup.event': second }), + ]) as unknown as PluginModuleMap; + + const eventMap = getPluginsEventMap(plugins); + + expect(eventMap.size).toBe(1); + expect(eventMap.get('dup.event')).toBe(second); +}); diff --git a/plugins/ui/src/js/src/events/usePluginsEventMap.ts b/plugins/ui/src/js/src/events/usePluginsEventMap.ts new file mode 100644 index 000000000..99509fbbb --- /dev/null +++ b/plugins/ui/src/js/src/events/usePluginsEventMap.ts @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { usePlugins } from '@deephaven/plugin'; +import Log from '@deephaven/log'; +import { type UIEventHandler, isEventPlugin } from './EventPlugin'; + +const log = Log.module('usePluginsEventMap'); + +/** + * Get a mapping of event names to their handlers from the given plugin map. + * + * If multiple plugins register a handler for the same event name, the last one + * registered wins and a warning is logged. + * + * @param pluginMap The plugin map to extract event plugins from. + * @returns A Map of event names to their handlers. + */ +export function getPluginsEventMap( + pluginMap: ReturnType +): Map { + const eventMap = new Map(); + [...pluginMap.values()].filter(isEventPlugin).forEach(plugin => { + Object.entries(plugin.eventMapping).forEach(([name, handler]) => { + if (eventMap.has(name)) { + log.warn( + `Multiple plugins registered a handler for event "${name}". The last one registered will be used.` + ); + } + eventMap.set(name, handler); + }); + }); + return eventMap; +} + +/** + * Get all event handlers registered by {@link EventPlugin}s from the plugins + * context. + * @returns A Map of event names to their handlers. + */ +export function usePluginsEventMap(): Map { + const plugins = usePlugins(); + return useMemo(() => getPluginsEventMap(plugins), [plugins]); +} + +export default usePluginsEventMap; diff --git a/plugins/ui/src/js/src/index.ts b/plugins/ui/src/js/src/index.ts index ec3c08e3e..356c867d6 100644 --- a/plugins/ui/src/js/src/index.ts +++ b/plugins/ui/src/js/src/index.ts @@ -22,4 +22,11 @@ const UIMultiPlugin = { export { DashboardPlugin }; +export { + type EventPlugin, + type UIEventHandler, + type UIEventMapping, + isEventPlugin, +} from './events/EventPlugin'; + export default UIMultiPlugin; diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx index e583bdf7d..09258c2c9 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.test.tsx @@ -926,3 +926,74 @@ describe('popstate listener', () => { removeEventListenerSpy.mockRestore(); }); }); + +describe('event plugin handling', () => { + async function setupWidgetWithPlugins( + pluginsValue: PluginModuleMap + ): Promise<{ + listener: (event: WidgetMessageEvent) => void; + unmount: () => void; + }> { + const widget = makeWidgetDescriptor(); + const cleanup = jest.fn(); + const mockAddEventListener = jest.fn( + (() => cleanup) as dh.Widget['addEventListener'] + ); + const initialData = { state: { test: 'value' } }; + mockWidgetWrapper = { + widget: makeWidget({ + addEventListener: mockAddEventListener, + getDataAsString: jest.fn(() => ''), + sendMessage: jest.fn(), + }), + error: null, + api: jest.fn() as unknown as typeof dh, + }; + + const { unmount } = render( + makeWidgetHandler({ widgetDescriptor: widget, initialData, pluginsValue }) + ); + + const listener = mockAddEventListener.mock.calls[0][1]; + + await act(async () => { + listener(makeWidgetEventJsonRpcResponse(0)); + }); + + return { listener, unmount }; + } + + function makeEventPlugin( + name: string, + eventMapping: Record) => void> + ): PluginModuleMap { + return new Map([ + [ + name, + { + name, + type: 'ElementPlugin', + mapping: {}, + eventMapping, + }, + ], + ]) as unknown as PluginModuleMap; + } + + it('dispatches a custom event to a registered event plugin handler', async () => { + const handler = jest.fn(); + const plugins = makeEventPlugin('test-event-plugin', { + 'test.event': handler, + }); + + const { listener, unmount } = await setupWidgetWithPlugins(plugins); + + await act(async () => { + listener(makeWidgetEventMethodEvent('test.event', { foo: 'bar' })); + }); + + expect(handler).toHaveBeenCalledWith({ foo: 'bar' }); + + unmount(); + }); +}); diff --git a/plugins/ui/src/js/src/widget/WidgetHandler.tsx b/plugins/ui/src/js/src/widget/WidgetHandler.tsx index b22d4357c..f991477dc 100644 --- a/plugins/ui/src/js/src/widget/WidgetHandler.tsx +++ b/plugins/ui/src/js/src/widget/WidgetHandler.tsx @@ -47,21 +47,19 @@ import DocumentHandler from './DocumentHandler'; import { transformNode, getComponentForElement, + getHandlerForEvent, wrapCallable, } from './WidgetUtils'; import WidgetStatusContext, { type WidgetStatus, } from '../layout/WidgetStatusContext'; import WidgetErrorView from './WidgetErrorView'; -import Toast, { TOAST_EVENT } from '../events/Toast'; -import showNotification, { NOTIFICATION_EVENT } from '../events/Notification'; -import playTone, { TONE_EVENT } from '../events/Tone'; import Navigate, { - NAVIGATE_EVENT, type NavigateParams, URL_CHANGED_EVENT, } from '../events/Navigate'; import NavigateContext from '../events/NavigateContext'; +import { usePluginsEventMap } from '../events/usePluginsEventMap'; import UriExportedObject from './UriExportedObject'; import applyJsonPatch from './WidgetJsonPatch'; @@ -226,6 +224,7 @@ function WidgetHandler({ ); const pluginsElementMap = usePluginsElementMap(); + const pluginsEventMap = usePluginsEventMap(); const renderErrorDocument = useCallback( (docError: NonNullable) => { @@ -466,22 +465,11 @@ function WidgetHandler({ } return value; }); - switch (name) { - case TOAST_EVENT: - Toast(eventParams); - break; - case NOTIFICATION_EVENT: - showNotification(eventParams); - break; - case TONE_EVENT: - playTone(eventParams); - break; - case NAVIGATE_EVENT: - Navigate(eventParams); - break; - default: - throw new Error(`Unknown event ${name}`); + const handler = getHandlerForEvent(name, pluginsEventMap); + if (handler == null) { + throw new Error(`Unknown event ${name}`); } + handler(eventParams); } catch (e) { throw new Error( `Error parsing event ${name} with payload ${payload}: ${e}` @@ -493,7 +481,13 @@ function WidgetHandler({ jsonClient.rejectAllPendingRequests('Widget was changed'); }; }, - [jsonClient, onDataChange, callableFinalizationRegistry, sendSetState] + [ + jsonClient, + onDataChange, + callableFinalizationRegistry, + sendSetState, + pluginsEventMap, + ] ); /** diff --git a/plugins/ui/src/js/src/widget/WidgetUtils.tsx b/plugins/ui/src/js/src/widget/WidgetUtils.tsx index 7d67a13e4..facbbd2ad 100644 --- a/plugins/ui/src/js/src/widget/WidgetUtils.tsx +++ b/plugins/ui/src/js/src/widget/WidgetUtils.tsx @@ -53,6 +53,11 @@ import { ELEMENT_NAME, type ElementName, } from '../elements/model/ElementConstants'; +import Toast, { TOAST_EVENT } from '../events/Toast'; +import showNotification, { NOTIFICATION_EVENT } from '../events/Notification'; +import playTone, { TONE_EVENT } from '../events/Tone'; +import Navigate, { NAVIGATE_EVENT } from '../events/Navigate'; +import { type UIEventHandler } from '../events/EventPlugin'; import ReactPanel from '../layout/ReactPanel'; import Row from '../layout/Row'; import Stack from '../layout/Stack'; @@ -267,6 +272,40 @@ export function getComponentForElement( return newElement.props?.children as JSX.Element | null; } +/** + * Widen a handler with a specific params type to the generic `UIEventHandler` + * signature. The params are decoded from the server payload, so they are not + * type checked at compile time. + */ +function asEventHandler(handler: (params: T) => void): UIEventHandler { + return handler as (params: unknown) => void; +} + +/** + * Map event names to their built-in handlers + */ +export const eventHandlerMap: Record = { + [TOAST_EVENT]: asEventHandler(Toast), + [NOTIFICATION_EVENT]: asEventHandler(showNotification), + [TONE_EVENT]: asEventHandler(playTone), + [NAVIGATE_EVENT]: asEventHandler(Navigate), +}; + +/** + * Get the handler for an event sent from the server. Built-in handlers take + * precedence over handlers registered by plugins. + * + * @param name The name of the event + * @param eventMap Map of event names to handlers registered by plugins + * @returns The handler for the event, or null if there is no handler + */ +export function getHandlerForEvent( + name: string, + eventMap: ReadonlyMap = EMPTY_MAP +): UIEventHandler | null { + return eventHandlerMap[name] ?? eventMap.get(name) ?? null; +} + /** * Deeply transform a given object depth-first and return a new object given a transform function. * Useful for iterating through an object and converting values. diff --git a/templates/README.md b/templates/README.md index a30ea5637..80b4a12da 100644 --- a/templates/README.md +++ b/templates/README.md @@ -5,11 +5,13 @@ In order to use these templates, you must have [cookiecutter](https://cookiecutt There are two main ways to use these templates. If you have this repository locally, you can run the following command from where you want to create your plugin: + ```sh cookiecutter /templates/