From a2f5207d1bdcc3e888feb2a14851ecac13c67455 Mon Sep 17 00:00:00 2001 From: Jakob Pinterits Date: Tue, 28 May 2024 17:37:12 +0000 Subject: [PATCH] added `on_window_size_change` event --- frontend/code/app.ts | 27 +++++--- frontend/code/componentManagement.ts | 2 +- frontend/code/components/plot.ts | 2 +- frontend/code/components/switcher.ts | 1 - frontend/code/designApplication.ts | 2 +- frontend/code/eventHandling.ts | 9 --- frontend/code/eventRateLimiter.ts | 34 ++++++++++ frontend/code/rpc.ts | 4 +- frontend/code/rpcFunctions.ts | 2 +- frontend/code/utils.ts | 3 +- rio/app_server.py | 2 +- rio/components/component.py | 5 ++ rio/event.py | 93 +++++++++++++++++++++------- rio/session.py | 41 ++++++------ 14 files changed, 157 insertions(+), 70 deletions(-) create mode 100644 frontend/code/eventRateLimiter.ts diff --git a/frontend/code/app.ts b/frontend/code/app.ts index e232ee88..9278ecb3 100644 --- a/frontend/code/app.ts +++ b/frontend/code/app.ts @@ -54,6 +54,20 @@ const SCROLL_BAR_SIZE_IN_PIXELS = getScrollBarWidthInPixels(); export let pixelsPerRem = 16; export let scrollBarSize = SCROLL_BAR_SIZE_IN_PIXELS / pixelsPerRem; +let notifyBackendOfWindowSizeChange = eventRateLimiter( + (newWidth: number, newHeight: number) => { + try { + callRemoteMethodDiscardResponse('onWindowSizeChange', { + newWidth: newWidth, + newHeight: newHeight, + }); + } catch (e) { + console.warn(`Couldn't notify backend of window resize: ${e}`); + } + }, + 250 +); + function main(): void { if (typeof globalThis.PING_PONG_INTERVAL_SECONDS !== 'number') { console.error( @@ -100,7 +114,7 @@ function main(): void { // Listen for URL changes, so the session can switch page window.addEventListener('popstate', (event: PopStateEvent) => { - console.log( + console.debug( `popstate event triggered; new URL is ${window.location.href}` ); @@ -115,7 +129,7 @@ function main(): void { // ScrollTarget, but not both. // FIXME: Find a way to tell whether only the url fragment changed - + console.trace(`URL changed to ${window.location.href}`); callRemoteMethodDiscardResponse('onUrlChange', { newUrl: window.location.href.toString(), }); @@ -124,14 +138,7 @@ function main(): void { // Listen for resize events window.addEventListener('resize', (event) => { // Notify the backend - try { - callRemoteMethodDiscardResponse('onWindowResize', { - newWidth: window.innerWidth / pixelsPerRem, - newHeight: window.innerHeight / pixelsPerRem, - }); - } catch (e) { - console.warn(`Couldn't notify backend of window resize: ${e}`); - } + notifyBackendOfWindowSizeChange(window.innerWidth, window.innerHeight); // Re-layout, but only if a root component already exists let rootElement = document.body.querySelector( diff --git a/frontend/code/componentManagement.ts b/frontend/code/componentManagement.ts index 280a3d23..25796cd3 100644 --- a/frontend/code/componentManagement.ts +++ b/frontend/code/componentManagement.ts @@ -473,7 +473,7 @@ export function updateComponentStates( Math.abs(deltaState._size_![1] - component.state._size_[1]) > 1e-6; if (width_changed || height_changed) { - console.log( + console.trace( `Triggering re-layout because component #${id} changed size: ${component.state._size_} -> ${deltaState._size_}` ); component.makeLayoutDirty(); diff --git a/frontend/code/components/plot.ts b/frontend/code/components/plot.ts index a868d42d..b2d7a609 100644 --- a/frontend/code/components/plot.ts +++ b/frontend/code/components/plot.ts @@ -37,7 +37,7 @@ function withPlotly(callback: () => void): void { } // Otherwise fetch plotly and call the callback when it's done - console.log('Fetching plotly.js'); + console.trace('Fetching plotly.js'); let script = document.createElement('script'); script.src = '/rio/asset/plotly.min.js'; diff --git a/frontend/code/components/switcher.ts b/frontend/code/components/switcher.ts index a8bb4fc8..4941878c 100644 --- a/frontend/code/components/switcher.ts +++ b/frontend/code/components/switcher.ts @@ -126,7 +126,6 @@ export class SwitcherComponent extends ComponentBase { this.previousChildRequestedWidth !== childRequestedWidth || this.previousChildRequestedHeight !== childRequestedHeight ) { - console.debug('Detected child size change, starting animation'); this.isDeterminingLayout = true; this.previousChildRequestedWidth = childRequestedWidth; this.previousChildRequestedHeight = childRequestedHeight; diff --git a/frontend/code/designApplication.ts b/frontend/code/designApplication.ts index 95a539e4..f73e3229 100644 --- a/frontend/code/designApplication.ts +++ b/frontend/code/designApplication.ts @@ -257,7 +257,7 @@ export async function applyIcon( // No, load it from the server if (promise === undefined) { - console.log(`Fetching icon ${iconName} from server`); + console.trace(`Fetching icon ${iconName} from server`); promise = fetch(`/rio/icon/${iconName}`).then((response) => response.text() diff --git a/frontend/code/eventHandling.ts b/frontend/code/eventHandling.ts index 26e94d81..56a269df 100644 --- a/frontend/code/eventHandling.ts +++ b/frontend/code/eventHandling.ts @@ -88,9 +88,7 @@ export class DragHandler extends EventHandler { } private _onMouseDown(event: MouseEvent): void { - console.debug('Drag: mouse down'); let onStartResult = this.onStart(event); - console.debug('Drag: onStart result', onStartResult); // It's easy to forget to return a boolean. Make sure to catch this // mistake. @@ -105,7 +103,6 @@ export class DragHandler extends EventHandler { return; } - console.debug('Drag: mouse down stop propagation'); event.stopPropagation(); window.addEventListener('mousemove', this.onMouseMove, true); @@ -114,7 +111,6 @@ export class DragHandler extends EventHandler { } private _onMouseMove(event: MouseEvent): void { - console.debug('Drag: mouse move'); this.hasDragged = true; event.stopPropagation(); @@ -122,9 +118,7 @@ export class DragHandler extends EventHandler { } private _onMouseUp(event: MouseEvent): void { - console.debug('Drag: mouse up'); if (this.hasDragged) { - console.debug('Preventing default due to drag'); event.stopPropagation(); } @@ -137,10 +131,7 @@ export class DragHandler extends EventHandler { // nonetheless be stopped to prevent the click from being handled by // other handlers. - console.debug('Drag: click'); - if (this.hasDragged) { - console.debug('Preventing default due to drag'); event.stopPropagation(); event.stopImmediatePropagation(); event.preventDefault(); diff --git a/frontend/code/eventRateLimiter.ts b/frontend/code/eventRateLimiter.ts new file mode 100644 index 00000000..ba941e49 --- /dev/null +++ b/frontend/code/eventRateLimiter.ts @@ -0,0 +1,34 @@ +/** + * Creates a rate-limited version of the given function. The function will be + * called at most once every `delay` milliseconds. It is also guaranteed to be + * called at least once with the final set of arguments passed to the original + * function. + * + * @param callback - The function to be called when the window resizes. + * @param delay - The delay in milliseconds between function calls. + * @returns A function that removes the event listener. + */ +function eventRateLimiter( + callback: (...args: any[]) => void, + delay: number +): (...args: any[]) => void { + let timeout: number | null = null; + let lastArgs: any[] = []; + + // Create a closure over the state + return (...args: any[]) => { + // Store the arguments, so future calls can use them + lastArgs = args; + + // If a timeout is already set, do nothing + if (timeout) { + return; + } + + // Set a timeout to call the function + timeout = window.setTimeout(() => { + timeout = null; + callback(...lastArgs); + }, delay); + }; +} diff --git a/frontend/code/rpc.ts b/frontend/code/rpc.ts index 2ae1840b..cc242d3b 100644 --- a/frontend/code/rpc.ts +++ b/frontend/code/rpc.ts @@ -174,7 +174,7 @@ function onMessage(event: MessageEvent) { // Print a copy of the message because some messages are modified in-place // when they're processed - console.log('Received message: ', JSON.parse(event.data)); + console.trace('Received message: ', JSON.parse(event.data)); // Push it into the queue, to be processed as soon as the previous message // has been processed @@ -234,7 +234,7 @@ export function sendMessageOverWebsocket(message: object) { return; } - console.log('Sending message: ', message); + console.trace('Sending message: ', message); websocket.send(JSON.stringify(message)); } diff --git a/frontend/code/rpcFunctions.ts b/frontend/code/rpcFunctions.ts index a03be83b..7708cb56 100644 --- a/frontend/code/rpcFunctions.ts +++ b/frontend/code/rpcFunctions.ts @@ -44,7 +44,7 @@ export async function registerFont( } if (numFailures === 0) { - console.log( + console.trace( `Successfully registered all ${numSuccesses} variations of font ${name}` ); } else if (numSuccesses === 0) { diff --git a/frontend/code/utils.ts b/frontend/code/utils.ts index 4e4f89c3..6dc6bccb 100644 --- a/frontend/code/utils.ts +++ b/frontend/code/utils.ts @@ -86,7 +86,8 @@ export function range(start: number, end: number): number[] { return result; } -/// Returns the first argument that isn't `undefined`. +/// Returns the first argument that isn't `undefined`. Returns `undefined` if +/// none of the arguments are defined. export function firstDefined(...args: any[]): any { for (let arg of args) { if (arg !== undefined) { diff --git a/rio/app_server.py b/rio/app_server.py index 87cd731d..67592c37 100644 --- a/rio/app_server.py +++ b/rio/app_server.py @@ -921,7 +921,7 @@ Sitemap: {request_url.with_path("/rio/sitemap")} js_page_url = json.dumps(str(active_page_url_absolute)) await sess._evaluate_javascript( f""" - console.log("Updating browser URL to match the one modified by guards:", {js_page_url}); + console.trace("Updating browser URL to match the one modified by guards:", {js_page_url}); window.history.replaceState(null, "", {js_page_url}); """ ) diff --git a/rio/components/component.py b/rio/components/component.py index 53862db8..f234811a 100644 --- a/rio/components/component.py +++ b/rio/components/component.py @@ -269,6 +269,11 @@ class ComponentMeta(RioDataclassMeta): callbacks = tuple(handler for handler, unused in event_handlers) session._page_change_callbacks[component] = callbacks + # Window resizes are handled by the session. Register the handler + elif event_tag == event.EventTag.ON_WINDOW_SIZE_CHANGE: + callbacks = tuple(handler for handler, unused in event_handlers) + session._on_window_size_change_callbacks[component] = callbacks + # The `periodic` event needs a task to work in elif event_tag == event.EventTag.PERIODIC: for callback, period in event_handlers: diff --git a/rio/event.py b/rio/event.py index aa7149eb..26c434ae 100644 --- a/rio/event.py +++ b/rio/event.py @@ -9,6 +9,7 @@ __all__ = [ "on_page_change", "on_populate", "on_unmount", + "on_window_size_change", "periodic", ] @@ -39,12 +40,20 @@ class EventTag(enum.Enum): ON_PAGE_CHANGE = enum.auto() ON_POPULATE = enum.auto() ON_UNMOUNT = enum.auto() + ON_WINDOW_SIZE_CHANGE = enum.auto() PERIODIC = enum.auto() -def _register_as_event_handler( - function: Callable, tag: EventTag, args: Any +def _tag_as_event_handler( + function: Callable, + tag: EventTag, + args: Any, ) -> None: + """ + Registers the function as an event handler for the given tag. This simply + adds a marker to the function's `__dict__` so that it can later be + recognized as an event handler. + """ all_events: dict[EventTag, list[Any]] = vars(function).setdefault( "_rio_events_", {} ) @@ -56,10 +65,14 @@ def on_mount(handler: MethodWithNoParametersVar) -> MethodWithNoParametersVar: """ Triggered when the component is added to the component tree. + This decorator makes the decorated method an event handler for `on_mount` + events. The method will be called whenever the component is added to the + component tree. + This may be triggered multiple times if the component is removed and then re-added. """ - _register_as_event_handler(handler, EventTag.ON_MOUNT, None) + _tag_as_event_handler(handler, EventTag.ON_MOUNT, None) return handler @@ -68,19 +81,14 @@ def on_page_change( ) -> MethodWithNoParametersVar: """ Triggered whenever the session changes pages. - """ - _register_as_event_handler(handler, EventTag.ON_PAGE_CHANGE, None) - return handler + Makes the decorated method an event handler for `on_page_change` events. + The method will be called whenever the session navigates to a new page. -def on_unmount(handler: MethodWithNoParametersVar) -> MethodWithNoParametersVar: + If you want your code to run both when the component was first created _and_ + when the page changes, you can combine this decorator with `on_populate`. """ - Triggered when the component is removed from the component tree. - - This may be triggered multiple times if the component is removed and then - re-added. - """ - _register_as_event_handler(handler, EventTag.ON_UNMOUNT, None) + _tag_as_event_handler(handler, EventTag.ON_PAGE_CHANGE, None) return handler @@ -88,11 +96,51 @@ def on_populate( handler: MethodWithNoParametersVar, ) -> MethodWithNoParametersVar: """ - Triggered after the component has been created or has been reconciled. This - allows you to asynchronously fetch any data which depends on the component's - state. + Triggered when the component has been created or has been reconciled. + + This decorator makes the decorated method an event handler for `on_populate` + events. The method will be called whenever the component has been created or + has been reconciled. This allows you to asynchronously fetch any data right + after component initialization. """ - _register_as_event_handler(handler, EventTag.ON_POPULATE, None) + + _tag_as_event_handler(handler, EventTag.ON_POPULATE, None) + return handler + + +def on_unmount(handler: MethodWithNoParametersVar) -> MethodWithNoParametersVar: + """ + Triggered when the component is removed from the component tree. + + This decorator makes the decorated method an event handler for `on_unmount` + events. The method will be called whenever the component is removed from the + component tree. + + This may be triggered multiple times if the component is removed and then + re-added. + """ + _tag_as_event_handler(handler, EventTag.ON_UNMOUNT, None) + return handler + + +def on_window_size_change( + handler: MethodWithNoParametersVar, +) -> MethodWithNoParametersVar: + """ + Triggered when the client's window is resized. + + This decorator makes the decorated method an event handler for + `on_window_size_change` events. The method will be called whenever the + window changes size. You can access the window's size using + `self.session.window_width` and `self.session.window_height` as usual. + + Some of the ways that a window can resize are non obvious. For example, + rotating a mobile device will trigger this event, since width and height + trade places. This event may also be triggered when the browser's dev tools + are opened or closed, or when the browser's zoom level is changed, since all + of those impact the available screen space. + """ + _tag_as_event_handler(handler, EventTag.ON_WINDOW_SIZE_CHANGE, None) return handler @@ -100,9 +148,12 @@ def periodic( interval: float | timedelta, ) -> Decorator[MethodWithNoParametersVar]: """ - This event is triggered repeatedly at a fixed time interval for as long as - the component exists. The component does not have to be mounted for this - event to trigger. + Triggered at a fixed time interval. + + This decorator causes the decorated method to be called repeatedly at a + fixed time interval. The event will be triggered for as long as the + component exists, even if it is not mounted. The interval can be specified + as either a number of seconds or as a timedelta. The interval only starts counting after the previous handler has finished executing, so the handler will never run twice simultaneously, even if it @@ -125,7 +176,7 @@ def periodic( def decorator( handler: MethodWithNoParametersVar, ) -> MethodWithNoParametersVar: - _register_as_event_handler(handler, EventTag.PERIODIC, interval) + _tag_as_event_handler(handler, EventTag.PERIODIC, interval) return handler return decorator diff --git a/rio/session.py b/rio/session.py index 64387100..e3090908 100644 --- a/rio/session.py +++ b/rio/session.py @@ -211,8 +211,8 @@ class Session(unicall.Unicall): # All components / methods which should be called when the session's # window size has changed. - self._on_window_resize_callbacks: weakref.WeakKeyDictionary[ - rio.Component, Callable[[rio.Component], None] + self._on_window_size_change_callbacks: weakref.WeakKeyDictionary[ + rio.Component, tuple[Callable[[rio.Component], None], ...] ] = weakref.WeakKeyDictionary() # All fonts which have been registered with the session. This maps the @@ -800,17 +800,12 @@ window.history.{method}(null, "", {json.dumps(str(active_page_url))}) ) # Trigger the `on_page_change` event - async def event_worker() -> None: - for component, callbacks in self._page_change_callbacks.items(): - for callback in callbacks: - self.create_task( - self._call_event_handler( - callback, component, refresh=True - ), - name="`on_page_change` event handler", - ) - - self.create_task(event_worker()) + for component, callbacks in self._page_change_callbacks.items(): + for callback in callbacks: + self.create_task( + self._call_event_handler(callback, component, refresh=True), + name="`on_page_change` event handler", + ) def _register_dirty_component( self, @@ -2428,8 +2423,8 @@ a.remove(); # Refresh the session await self._refresh() - @unicall.local(name="onWindowResize") - async def _on_window_resize( + @unicall.local(name="onWindowSizeChange") + async def _on_window_size_change( self, new_width: float, new_height: float ) -> None: """ @@ -2439,9 +2434,13 @@ a.remove(); self._window_width = new_width self._window_height = new_height - # Call any registered callbacks - for component, callback in self._on_window_resize_callbacks.items(): - self.create_task( - self._call_event_handler(callback, component, refresh=True), - name="`on_window_resize` event handler", - ) + # Trigger the `on_page_size_change` event + for ( + component, + callbacks, + ) in self._on_window_size_change_callbacks.items(): + for callback in callbacks: + self.create_task( + self._call_event_handler(callback, component, refresh=True), + name="`on_on_window_size_change_change` event handler", + )