Skip to content

Commit 98f1273

Browse files
authored
impr(commandline): improve performance (@fehmer) (monkeytypegame#6559)
!nuf
1 parent e42e90b commit 98f1273

File tree

3 files changed

+117
-51
lines changed

3 files changed

+117
-51
lines changed

frontend/__tests__/utils/numbers.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,33 @@ describe("numbers", () => {
4646
expect(Numbers.abbreviateNumber((number *= 1000))).toEqual("1.0d");
4747
});
4848
});
49+
describe("parseIntOptional", () => {
50+
it("should return a number when given a valid string", () => {
51+
expect(Numbers.parseIntOptional("123")).toBe(123);
52+
expect(Numbers.parseIntOptional("42")).toBe(42);
53+
expect(Numbers.parseIntOptional("0")).toBe(0);
54+
});
55+
56+
it("should return undefined when given null", () => {
57+
expect(Numbers.parseIntOptional(null)).toBeUndefined();
58+
});
59+
60+
it("should return undefined when given undefined", () => {
61+
expect(Numbers.parseIntOptional(undefined)).toBeUndefined();
62+
});
63+
64+
it("should handle non-numeric strings", () => {
65+
expect(Numbers.parseIntOptional("abc")).toBeNaN();
66+
expect(Numbers.parseIntOptional("12abc")).toBe(12); // parseInt stops at non-numeric chars
67+
});
68+
69+
it("should handle leading and trailing spaces", () => {
70+
expect(Numbers.parseIntOptional(" 42 ")).toBe(42);
71+
});
72+
it("should return a number when given a valid string and radix", () => {
73+
expect(Numbers.parseIntOptional("1010", 2)).toBe(10);
74+
expect(Numbers.parseIntOptional("CF", 16)).toBe(207);
75+
expect(Numbers.parseIntOptional("C", 26)).toBe(12);
76+
});
77+
});
4978
});

frontend/src/ts/commandline/commandline.ts

Lines changed: 72 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import * as ActivePage from "../states/active-page";
1111
import { focusWords } from "../test/test-ui";
1212
import * as Loader from "../elements/loader";
1313
import { Command, CommandsSubgroup } from "./types";
14+
import { areSortedArraysEqual } from "../utils/arrays";
15+
import { parseIntOptional } from "../utils/numbers";
16+
import { debounce } from "throttle-debounce";
1417

1518
type CommandlineMode = "search" | "input";
1619
type InputModeParams = {
@@ -325,6 +328,7 @@ function hideCommands(): void {
325328
throw new Error("Commandline element not found");
326329
}
327330
element.innerHTML = "";
331+
lastList = undefined;
328332
}
329333

330334
let cachedSingleSubgroup: CommandsSubgroup | null = null;
@@ -349,18 +353,24 @@ async function getList(): Promise<Command[]> {
349353
return (await getSubgroup()).list;
350354
}
351355

356+
let lastList: Command[] | undefined;
357+
352358
async function showCommands(): Promise<void> {
353359
const element = document.querySelector("#commandLine .suggestions");
354360
if (element === null) {
355361
throw new Error("Commandline element not found");
356362
}
357363

358364
if (inputValue === "" && usingSingleList) {
359-
element.innerHTML = "";
365+
hideCommands();
360366
return;
361367
}
362368

363369
const list = (await getList()).filter((c) => c.found === true);
370+
if (lastList && areSortedArraysEqual(list, lastList)) {
371+
return;
372+
}
373+
lastList = list;
364374

365375
let html = "";
366376
let index = 0;
@@ -458,28 +468,8 @@ async function showCommands(): Promise<void> {
458468
if (firstActive !== null && !usingSingleList) {
459469
activeIndex = firstActive;
460470
}
461-
element.innerHTML = html;
462471

463-
for (const command of element.querySelectorAll(".command")) {
464-
command.addEventListener("mouseenter", async () => {
465-
if (!mouseMode) return;
466-
activeIndex = parseInt(command.getAttribute("data-index") ?? "0");
467-
await updateActiveCommand();
468-
});
469-
command.addEventListener("mouseleave", async () => {
470-
if (!mouseMode) return;
471-
activeIndex = parseInt(command.getAttribute("data-index") ?? "0");
472-
await updateActiveCommand();
473-
});
474-
command.addEventListener("click", async () => {
475-
const previous = activeIndex;
476-
activeIndex = parseInt(command.getAttribute("data-index") ?? "0");
477-
if (previous !== activeIndex) {
478-
await updateActiveCommand();
479-
}
480-
await runActiveCommand();
481-
});
482-
}
472+
element.innerHTML = html;
483473
}
484474

485475
async function updateActiveCommand(): Promise<void> {
@@ -573,23 +563,20 @@ async function runActiveCommand(): Promise<void> {
573563
}
574564
}
575565

566+
let lastActiveIndex: string | undefined;
576567
function keepActiveCommandInView(): void {
577568
if (mouseMode) return;
578-
try {
579-
const scroll =
580-
Math.abs(
581-
($(".suggestions").offset()?.top as number) -
582-
($(".command.active").offset()?.top as number) -
583-
($(".suggestions").scrollTop() as number)
584-
) -
585-
($(".suggestions").outerHeight() as number) / 2 +
586-
($($(".command")[0] as HTMLElement).outerHeight() as number);
587-
$(".suggestions").scrollTop(scroll);
588-
} catch (e) {
589-
if (e instanceof Error) {
590-
console.log("could not scroll suggestions: " + e.message);
591-
}
569+
570+
const active: HTMLElement | null = document.querySelector(
571+
".suggestions .command.active"
572+
);
573+
574+
if (active === null || active.dataset["index"] === lastActiveIndex) {
575+
return;
592576
}
577+
578+
active.scrollIntoView({ behavior: "auto", block: "center" });
579+
lastActiveIndex = active.dataset["index"];
593580
}
594581

595582
function updateInput(setInput?: string): void {
@@ -665,22 +652,25 @@ const modal = new AnimatedModal({
665652
setup: async (modalEl): Promise<void> => {
666653
const input = modalEl.querySelector("input") as HTMLInputElement;
667654

668-
input.addEventListener("input", async (e) => {
669-
inputValue = (e.target as HTMLInputElement).value;
670-
if (subgroupOverride === null) {
671-
if (Config.singleListCommandLine === "on") {
672-
usingSingleList = true;
673-
} else {
674-
usingSingleList = inputValue.startsWith(">");
655+
input.addEventListener(
656+
"input",
657+
debounce(50, async (e) => {
658+
inputValue = (e.target as HTMLInputElement).value;
659+
if (subgroupOverride === null) {
660+
if (Config.singleListCommandLine === "on") {
661+
usingSingleList = true;
662+
} else {
663+
usingSingleList = inputValue.startsWith(">");
664+
}
675665
}
676-
}
677-
if (mode !== "search") return;
678-
mouseMode = false;
679-
activeIndex = 0;
680-
await filterSubgroup();
681-
await showCommands();
682-
await updateActiveCommand();
683-
});
666+
if (mode !== "search") return;
667+
mouseMode = false;
668+
activeIndex = 0;
669+
await filterSubgroup();
670+
await showCommands();
671+
await updateActiveCommand();
672+
})
673+
);
684674

685675
input.addEventListener("keydown", async (e) => {
686676
mouseMode = false;
@@ -740,5 +730,36 @@ const modal = new AnimatedModal({
740730
modalEl.addEventListener("mousemove", (_e) => {
741731
mouseMode = true;
742732
});
733+
734+
const suggestions = document.querySelector(".suggestions") as HTMLElement;
735+
let lastHover: HTMLElement | undefined;
736+
737+
suggestions.addEventListener("mousemove", async (e) => {
738+
const target = e.target as HTMLElement | null;
739+
if (target === lastHover) return;
740+
741+
const dataIndex = parseIntOptional(target?.getAttribute("data-index"));
742+
743+
if (!dataIndex) return;
744+
745+
lastHover = e.target as HTMLElement;
746+
activeIndex = dataIndex;
747+
await updateActiveCommand();
748+
});
749+
750+
suggestions.addEventListener("click", async (e) => {
751+
const target = e.target as HTMLElement | null;
752+
753+
const dataIndex = parseIntOptional(target?.getAttribute("data-index"));
754+
755+
if (!dataIndex) return;
756+
757+
const previous = activeIndex;
758+
activeIndex = dataIndex;
759+
if (previous !== activeIndex) {
760+
await updateActiveCommand();
761+
}
762+
await runActiveCommand();
763+
});
743764
},
744765
});

frontend/src/ts/utils/numbers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,19 @@ export function findLineByLeastSquares(
133133
];
134134
return [returnpoint1, returnpoint2];
135135
}
136+
137+
/**
138+
* Parses a string into an integer if it is not null or undefined, otherwise returns undefined.
139+
*
140+
* @param The string to parse or null or undefined.
141+
* @param radix A value between 2 and 36 that specifies the base of the number in `string`.
142+
* @returns A number if a string is provided, otherwise undefined.
143+
*/
144+
export function parseIntOptional<T extends string | null | undefined>(
145+
value: T,
146+
radix: number = 10
147+
): T extends string ? number : undefined {
148+
return (
149+
value !== null && value !== undefined ? parseInt(value, radix) : undefined
150+
) as T extends string ? number : undefined;
151+
}

0 commit comments

Comments
 (0)