much improved event rate limiting. text input now updates in real time

This commit is contained in:
Jakob Pinterits
2024-06-06 23:10:40 +02:00
parent 8262a55b42
commit 88b50c5b2f
7 changed files with 203 additions and 53 deletions
+1
View File
@@ -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
+8 -6
View File
@@ -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<void> {
// Display a warning if running in debug mode
@@ -127,7 +126,10 @@ async function main(): Promise<void> {
// 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(
+6 -7
View File
@@ -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<number, [string, string, string, string]> = 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 {
+18 -3
View File
@@ -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
+122
View File
@@ -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;
}
}
-34
View File
@@ -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);
};
}
+48 -3
View File
@@ -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