From 88b50c5b2f2e395ad51954b8d83ac4cdaa1401ab Mon Sep 17 00:00:00 2001 From: Jakob Pinterits Date: Thu, 6 Jun 2024 23:10:40 +0200 Subject: [PATCH] much improved event rate limiting. text input now updates in real time --- changelog.md | 1 + frontend/code/app.ts | 14 +-- frontend/code/components/layoutDisplay.ts | 13 ++- frontend/code/components/textInput.ts | 21 +++- frontend/code/debouncer.ts | 122 ++++++++++++++++++++++ frontend/code/eventRateLimiter.ts | 34 ------ rio/components/image.py | 51 ++++++++- 7 files changed, 203 insertions(+), 53 deletions(-) create mode 100644 frontend/code/debouncer.ts delete mode 100644 frontend/code/eventRateLimiter.ts diff --git a/changelog.md b/changelog.md index 1cd2e0d6..efaa0b48 100644 --- a/changelog.md +++ b/changelog.md @@ -16,6 +16,7 @@ - added `DateInput` component - massive dev-tools overhaul - new (but experimental) `Switcher` component +- TextInputs now update their text in real-time ## 0.8 diff --git a/frontend/code/app.ts b/frontend/code/app.ts index ca162abc..885c5d03 100644 --- a/frontend/code/app.ts +++ b/frontend/code/app.ts @@ -1,5 +1,5 @@ import { getComponentByElement } from './componentManagement'; -import { eventRateLimiter } from './eventRateLimiter'; +import { Debouncer } from './debouncer'; import { updateLayout } from './layouting'; import { callRemoteMethodDiscardResponse, @@ -47,8 +47,8 @@ const SCROLL_BAR_SIZE_IN_PIXELS = getScrollBarWidthInPixels(); export let pixelsPerRem = 16; export let scrollBarSize = SCROLL_BAR_SIZE_IN_PIXELS / pixelsPerRem; -let notifyBackendOfWindowSizeChange = eventRateLimiter( - (newWidthPx: number, newHeightPx: number) => { +let notifyBackendOfWindowSizeChange = new Debouncer({ + callback: (newWidthPx: number, newHeightPx: number) => { try { callRemoteMethodDiscardResponse('onWindowSizeChange', { newWidth: newWidthPx / pixelsPerRem, @@ -58,8 +58,7 @@ let notifyBackendOfWindowSizeChange = eventRateLimiter( console.warn(`Couldn't notify backend of window resize: ${e}`); } }, - 500 -); +}); async function main(): Promise { // Display a warning if running in debug mode @@ -127,7 +126,10 @@ async function main(): Promise { // Listen for resize events window.addEventListener('resize', (event) => { // Notify the backend - notifyBackendOfWindowSizeChange(window.innerWidth, window.innerHeight); + notifyBackendOfWindowSizeChange.call( + window.innerWidth, + window.innerHeight + ); // Re-layout, but only if a root component already exists let rootElement = document.body.querySelector( diff --git a/frontend/code/components/layoutDisplay.ts b/frontend/code/components/layoutDisplay.ts index 244383cc..f35c9a90 100644 --- a/frontend/code/components/layoutDisplay.ts +++ b/frontend/code/components/layoutDisplay.ts @@ -5,7 +5,7 @@ import { pixelsPerRem } from '../app'; import { getDisplayableChildren } from '../devToolsTreeWalk'; import { Highlighter } from '../highlighter'; import { DevToolsConnectorComponent } from './devToolsConnector'; -import { eventRateLimiter as rateLimit } from '../eventRateLimiter'; +import { Debouncer } from '../debouncer'; export type LayoutDisplayState = ComponentState & { _type_: 'LayoutDisplay-builtin'; @@ -31,7 +31,7 @@ export class LayoutDisplayComponent extends ComponentBase { // change allocated size, the content needs to update childrenToWatch: Map = new Map(); - rateLimitedNotifyBackendOfChange: () => void; + onChangeLimiter: Debouncer; createElement(): HTMLElement { // Register this component with the global dev tools component, so it @@ -85,10 +85,9 @@ export class LayoutDisplayComponent extends ComponentBase { }; // Create a rate-limited version of the notifyBackendOfChange function - this.rateLimitedNotifyBackendOfChange = rateLimit( - this._notifyBackendOfChange.bind(this), - 300 - ); + this.onChangeLimiter = new Debouncer({ + callback: this._notifyBackendOfChange.bind(this), + }); return element; } @@ -179,7 +178,7 @@ export class LayoutDisplayComponent extends ComponentBase { }, 0); // Tell the backend about it - this.rateLimitedNotifyBackendOfChange(); + this.onChangeLimiter.call(); } updateNaturalHeight(ctx: LayoutContext): void { diff --git a/frontend/code/components/textInput.ts b/frontend/code/components/textInput.ts index 26389061..e8466a63 100644 --- a/frontend/code/components/textInput.ts +++ b/frontend/code/components/textInput.ts @@ -6,6 +6,7 @@ import { updateInputBoxNaturalHeight, updateInputBoxNaturalWidth, } from '../inputBoxTools'; +import { Debouncer } from '../debouncer'; export type TextInputState = ComponentState & { _type_: 'TextInput-builtin'; @@ -29,6 +30,8 @@ export class TextInputComponent extends ComponentBase { private prefixTextWidth: number = 0; private suffixTextWidth: number = 0; + onChangeLimiter: Debouncer; + createElement(): HTMLElement { // Create the element let element = document.createElement('div'); @@ -51,13 +54,25 @@ export class TextInputComponent extends ComponentBase { element.querySelectorAll('.rio-text-input-hint-text') ) as HTMLElement[]; + // Create a rate-limited function for notifying the backend of change + this.onChangeLimiter = new Debouncer({ + callback: (newText: string) => { + this.setStateAndNotifyBackend({ + text: newText, + }); + }, + }); + // Detect value changes and send them to the backend this.inputElement = element.querySelector('input') as HTMLInputElement; + this.inputElement.addEventListener('input', () => { + this.onChangeLimiter.call(this.inputElement.value); + }); + this.inputElement.addEventListener('blur', () => { - this.setStateAndNotifyBackend({ - text: this.inputElement.value, - }); + this.onChangeLimiter.call(this.inputElement.value); + this.onChangeLimiter.flush(); }); // Detect the enter key and send it to the backend diff --git a/frontend/code/debouncer.ts b/frontend/code/debouncer.ts new file mode 100644 index 00000000..375621bd --- /dev/null +++ b/frontend/code/debouncer.ts @@ -0,0 +1,122 @@ +/// A helper class to rate-limit function calls. After creating a `Debouncer` +/// object, you can invoke its `call` method as quickly or as often as you like. +/// The debouncer will ensure that the function is called at a reasonable rate. +export class Debouncer { + private callback: (...args: any[]) => void; + + // Keep track of when the most recent call was requested + private mostRecentCallRequest: number = 0; + + // Keep track when the most recent call was actually made + private mostRecentPerformedCall: number = 0; + + // Keep track of how much time has passed between calls + private recentIntervals: number[] = []; + + // Pending arguments, if any + private pendingArguments: any[] | null = null; + + // If a call is pending, this is set to the `setTimeout` object + private timeout: number | null = null; + + // Updated to reflect how frequently requests to call the function are made + private medianInterval: number = 10; + + constructor(options: { callback: (...args: any[]) => void }) { + const { callback } = options; + + this.callback = callback; + } + + /// Requests that a call is made. The debouncer will decide when to actually + /// make the call. + public call(...args: any[]): void { + // Keep track of how long it has been since the last call was requested + let now = Date.now(); + let timeSinceLastCallRequest = now - this.mostRecentCallRequest; + this.recentIntervals.push(timeSinceLastCallRequest); + + // Don't let the recent intervals list get too long + if (this.recentIntervals.length > 10) { + this.recentIntervals.shift(); + } + + // Update the median interval + if (this.recentIntervals.length >= 1) { + let sorted = this.recentIntervals.slice().sort(); + this.medianInterval = sorted[Math.floor(sorted.length / 2)]; + } + + // Update the arguments the next call should be made with + this.pendingArguments = args; + + // Consider making the call + this.considerCalling(); + + // Record this call request, now that all logic has run + this.mostRecentCallRequest = now; + } + + considerCalling(): void { + // If no arguments are pending, there is nothing to do + if (this.pendingArguments === null) { + return; + } + + // Determine thresholds. If the time is past at least one of these + // the call will be made. + let pauseThreshold = + this.mostRecentCallRequest + 3 * this.medianInterval; + let timeoutThreshold = this.mostRecentPerformedCall + 500; + let combinedThreshold = Math.min(pauseThreshold, timeoutThreshold); + + // Call? + let now = Date.now(); + let shouldCallNow: boolean = now > combinedThreshold; + + // Yes! + if (shouldCallNow) { + this.flush(); + return; + } + + // This isn't the right time to make a call. Schedule a call for later, + // if there isn't already one scheduled. + if (this.timeout !== null) { + return; + } + + // Schedule a call + let waitTime = Math.max(combinedThreshold - now, 20); + + this.timeout = setTimeout(() => { + this.timeout = null; + this.considerCalling(); + }, waitTime); + } + + /// Inform the debouncer that the user has finished interacting with the + /// interface, indicating to the debouncer that it should call the function + /// as soon as possible, if there are any pending arguments. + /// + /// This can be useful if the caller has additional information, such as + /// knowing that the user has finished typing in a text field due to a blur + /// event. + public flush(): void { + // If no call is pending there is nothing to do + if (this.pendingArguments === null) { + return; + } + + // Perform the call, taking care not to crash + try { + this.callback(...this.pendingArguments); + } catch (e) { + console.error(`Failed to call debounced function: ${e}`); + } + + // Housekeeping + this.mostRecentPerformedCall = Date.now(); + this.pendingArguments = null; + } +} diff --git a/frontend/code/eventRateLimiter.ts b/frontend/code/eventRateLimiter.ts deleted file mode 100644 index 37b59062..00000000 --- a/frontend/code/eventRateLimiter.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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. - */ -export 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/rio/components/image.py b/rio/components/image.py index 97eaa262..ea117234 100644 --- a/rio/components/image.py +++ b/rio/components/image.py @@ -20,9 +20,14 @@ class Image(FundamentalComponent): `Image` does just what you'd expect: it displays a single image. The image can be loaded from a URL or a local file. - Note that the resolution of the image does not affect the size at which it - is displayed. The `Image` component is flexible with its space requirements - and adapts to any space allocated by its parent component. + The resolution of the image does not affect the size at which it is + displayed. The `Image` component is flexible with its space requirements and + adapts to any space allocated by its parent component. + + Note that unlike most components in Rio, the `Image` component does not have + a `natural` size, since images can be easily be scaled to fit any space. + Because of this, `Image` defaults to a width and height of 2. This avoids + invisible images when you forget to set the size. The actual picture content can be scaled to fit the assigned shape in one of three ways: @@ -106,6 +111,46 @@ class Image(FundamentalComponent): on_error: EventHandler[[]] = None corner_radius: float | tuple[float, float, float, float] = 0 + def __init__( + self, + image: ImageLike, + *, + fill_mode: Literal["fit", "stretch", "zoom"] = "fit", + on_error: EventHandler[[]] | None = None, + corner_radius: float | tuple[float, float, float, float] = 0, + key: str | None = None, + margin: float | None = None, + margin_x: float | None = None, + margin_y: float | None = None, + margin_left: float | None = None, + margin_top: float | None = None, + margin_right: float | None = None, + margin_bottom: float | None = None, + width: float | Literal["grow"] = 2, + height: float | Literal["grow"] = 2, + align_x: float | None = None, + align_y: float | None = None, + ) -> None: + super().__init__( + key=key, + margin=margin, + margin_x=margin_x, + margin_y=margin_y, + margin_left=margin_left, + margin_top=margin_top, + margin_right=margin_right, + margin_bottom=margin_bottom, + width=width, + height=height, + align_x=align_x, + align_y=align_y, + ) + + self.image = image + self.fill_mode = fill_mode + self.on_error = on_error + self.corner_radius = corner_radius + def _get_image_asset(self) -> assets.Asset: image = self.image