Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
{
"exclude": [
"docs/**",
".coverage/**"
".coverage/**",
".worktrees/**"
],
"tasks": {
"check": "deno check ./**/*.ts",
Expand Down
24 changes: 22 additions & 2 deletions denops/fall/component/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export class InputComponent extends BaseComponent {
#modifiedWindow = true;
#modifiedContent = true;

// Cache for byte lengths to avoid repeated calculations
#prefixCache?: { value: string; byteLength: number };
#suffixCache?: { value: string; byteLength: number };

constructor(
{
title,
Expand Down Expand Up @@ -287,9 +291,25 @@ export class InputComponent extends BaseComponent {
this.#offset,
this.#offset + cmdwidth,
);
const prefixByteLength = getByteLength(prefix);

// Use cached byte lengths when possible
let prefixByteLength: number;
if (this.#prefixCache?.value === prefix) {
prefixByteLength = this.#prefixCache.byteLength;
} else {
prefixByteLength = getByteLength(prefix);
this.#prefixCache = { value: prefix, byteLength: prefixByteLength };
}

const middleByteLength = getByteLength(middle);
const suffixByteLength = getByteLength(suffix);

let suffixByteLength: number;
if (this.#suffixCache?.value === suffix) {
suffixByteLength = this.#suffixCache.byteLength;
} else {
suffixByteLength = getByteLength(suffix);
this.#suffixCache = { value: suffix, byteLength: suffixByteLength };
}

await buffer.replace(denops, bufnr, [prefix + middle + suffix]);
signal?.throwIfAborted();
Expand Down
8 changes: 7 additions & 1 deletion denops/fall/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ export function dispatch(event: Readonly<Event>): void {
}

export function consume(consumer: Consumer): void {
// Optimize: Swap arrays instead of creating new ones each time
const events = eventQueue;
if (events.length === 0) return;

eventQueue = [];
events.forEach(consumer);
// Use for loop instead of forEach for better performance
for (let i = 0; i < events.length; i++) {
consumer(events[i]);
}
}

type SelectMethod = "on" | "off" | "toggle";
Expand Down
76 changes: 76 additions & 0 deletions denops/fall/event_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,80 @@ Deno.test("Event", async (t) => {
});
assertEquals(dispatchedEvents, []);
});

await t.step("multiple consumers receive all events in order", () => {
dispatch({ type: "vim-cmdline-changed", cmdline: "test1" });
dispatch({ type: "vim-cmdpos-changed", cmdpos: 5 });
dispatch({ type: "vim-cmdline-changed", cmdline: "test2" });

const results: Event[][] = [];
consume((event) => {
if (!results[0]) results[0] = [];
results[0].push(event);
});

assertEquals(results[0], [
{ type: "vim-cmdline-changed", cmdline: "test1" },
{ type: "vim-cmdpos-changed", cmdpos: 5 },
{ type: "vim-cmdline-changed", cmdline: "test2" },
]);
});

await t.step("handles large number of events", () => {
const eventCount = 10000;
for (let i = 0; i < eventCount; i++) {
dispatch({ type: "vim-cmdpos-changed", cmdpos: i });
}

let receivedCount = 0;
consume((event) => {
assertEquals(event.type, "vim-cmdpos-changed");
receivedCount++;
});

assertEquals(receivedCount, eventCount);
});

await t.step("events are cleared after consume", () => {
dispatch({ type: "vim-cmdline-changed", cmdline: "test" });

let firstConsumeCount = 0;
consume(() => {
firstConsumeCount++;
});
assertEquals(firstConsumeCount, 1);

let secondConsumeCount = 0;
consume(() => {
secondConsumeCount++;
});
assertEquals(secondConsumeCount, 0);
});

await t.step("handles events dispatched during consume", () => {
dispatch({ type: "vim-cmdline-changed", cmdline: "initial" });

const events: Event[] = [];
consume((event) => {
events.push(event);
if (event.type === "vim-cmdline-changed" && event.cmdline === "initial") {
// This dispatch happens during consume - should not be consumed in this cycle
dispatch({ type: "vim-cmdpos-changed", cmdpos: 42 });
}
});

assertEquals(events, [
{ type: "vim-cmdline-changed", cmdline: "initial" },
]);

// The event dispatched during consume should be available in next consume
const nextEvents: Event[] = [];
consume((event) => {
nextEvents.push(event);
});

assertEquals(nextEvents, [
{ type: "vim-cmdpos-changed", cmdpos: 42 },
]);
});
});
65 changes: 65 additions & 0 deletions denops/fall/lib/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export type DebounceOptions = {
delay?: number;
signal?: AbortSignal;
};

/**
* Creates a debounced function that delays invoking the provided function until after
* the specified delay has elapsed since the last time the debounced function was invoked.
*
* @param fn - The function to debounce
* @param options - Configuration options
* @param options.delay - The number of milliseconds to delay (default: 0)
* @param options.signal - An optional AbortSignal to cancel the debounced function
* @returns A debounced version of the function
*
* @example
* ```ts
* import { debounce } from "./debounce.ts";
* import { delay } from "jsr:@std/async@^1.0.0/delay";
*
* const saveData = () => console.log("Saving data...");
* const debouncedSave = debounce(() => saveData(), { delay: 100 });
*
* // Multiple calls within 100ms will only trigger one save
* debouncedSave();
* debouncedSave();
* debouncedSave();
*
* // Wait for the debounced function to execute
* await delay(150);
*
* // Cancel via AbortSignal
* const doWork = () => console.log("Doing work...");
* const controller = new AbortController();
* const debouncedFunc = debounce(() => doWork(), {
* delay: 50,
* signal: controller.signal
* });
* debouncedFunc();
* controller.abort(); // Cancels any pending execution
* ```
*/
// deno-lint-ignore no-explicit-any
export function debounce<F extends (...args: any[]) => void>(
fn: F,
{ delay, signal }: DebounceOptions = {},
): F {
let timerId: number | undefined;

const abort = () => {
if (timerId !== undefined) {
clearTimeout(timerId);
timerId = undefined;
}
};

signal?.addEventListener("abort", abort, { once: true });
return ((...args) => {
abort();
timerId = setTimeout(() => {
timerId = undefined;
fn(...args);
}, delay);
}) as F;
}
86 changes: 86 additions & 0 deletions denops/fall/lib/debounce_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { assertEquals } from "jsr:@std/assert@^1.0.6";
import { delay } from "jsr:@std/async@^1.0.0/delay";
import { FakeTime } from "jsr:@std/testing@^1.0.0/time";

import { debounce } from "./debounce.ts";

Deno.test("debounce", async (t) => {
await t.step("delays function execution", async () => {
using time = new FakeTime();

let callCount = 0;
const fn = debounce(() => {
callCount++;
}, { delay: 100 });

fn();
assertEquals(callCount, 0);

await time.tickAsync(50);
assertEquals(callCount, 0);

await time.tickAsync(50);
assertEquals(callCount, 1);
});

await t.step(
"cancels previous calls when called multiple times",
async () => {
using time = new FakeTime();

let callCount = 0;
let lastValue = 0;
const fn = debounce((value: number) => {
callCount++;
lastValue = value;
}, { delay: 100 });

fn(1);
await time.tickAsync(50);
fn(2);
await time.tickAsync(50);
fn(3);
await time.tickAsync(50);

assertEquals(callCount, 0);

await time.tickAsync(50);
assertEquals(callCount, 1);
assertEquals(lastValue, 3);
},
);

await t.step("works with real timers", async () => {
let callCount = 0;
const fn = debounce(() => {
callCount++;
}, { delay: 50 });

fn();
assertEquals(callCount, 0);

await delay(30);
assertEquals(callCount, 0);

await delay(30);
assertEquals(callCount, 1);
});

await t.step("respects abort signal", async () => {
using time = new FakeTime();

let callCount = 0;
const controller = new AbortController();
const fn = debounce(() => {
callCount++;
}, { delay: 100, signal: controller.signal });

fn();
await time.tickAsync(50);

controller.abort();

await time.tickAsync(100);
assertEquals(callCount, 0);
});
});
Loading