mirror of
https://github.com/rio-labs/rio.git
synced 2026-05-07 11:59:46 -05:00
much improved event rate limiting. text input now updates in real time
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user