+ testFocusState() === "unfocusedWindow"
+ ? "Click anywhere to focus the window"
+ : "Click here or press any key to focus";
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/ts/states/test.ts b/frontend/src/ts/states/test.ts
index 5212f3b47252..56bbed8d1190 100644
--- a/frontend/src/ts/states/test.ts
+++ b/frontend/src/ts/states/test.ts
@@ -15,6 +15,7 @@ import { canQuickRestart } from "../utils/quick-restart";
import { replaceUnderscoresWithSpaces } from "../utils/strings";
import { getActivePage, getCustomTextIndicator } from "./core";
import { useResourceWithPromise } from "../hooks/useResourceWithPromise";
+import { clearTimeouts } from "../utils/misc";
export const [wordsHaveNewline, setWordsHaveNewline] = createSignal(false);
export const [wordsHaveTab, setWordsHaveTab] = createSignal(false);
@@ -24,9 +25,35 @@ export const [getLoadedChallenge, setLoadedChallenge] =
createSignal
(null);
export const [getResultVisible, setResultVisible] = createSignal(false);
export const [getFocus, setFocus] = createSignal(false);
-// #words is still vanilla so it's blurred imperatively (see test/out-of-focus);
-// the Solid-owned composition display reads this signal instead.
-export const [isOutOfFocus, setOutOfFocus] = createSignal(false);
+// #words is still vanilla so it's blurred imperatively (see test/test-ui);
+// the Solid-owned composition display + OutOfFocusWarning read this signal.
+const outOfFocusTimeouts: (number | NodeJS.Timeout)[] = [];
+export type TestFocusState = "focused" | "unfocused" | "unfocusedWindow";
+export const [testFocusState, { setTestFocusState }] =
+ createSignalWithSetters("focused")({
+ setTestFocusState: (set, val: TestFocusState) => {
+ if (val === "focused") {
+ clearTimeouts(outOfFocusTimeouts);
+ set(val);
+ } else {
+ outOfFocusTimeouts.push(
+ setTimeout(() => {
+ set(val);
+ }, 1000),
+ );
+ }
+ },
+ });
+
+export const showOutOfFocusWarning = createMemo(
+ () => getConfig.showOutOfFocusWarning && testFocusState() !== "focused",
+);
+
+// max-height of the warning, kept in sync with the words wrapper by test-ui.
+export const [outOfFocusMaxHeight, setOutOfFocusMaxHeight] = createSignal<
+ number | undefined
+>(undefined);
+
// live IME composition text, pushed from the compositionupdate/end events.
export const [getCompositionText, setCompositionText] = createSignal("");
export const [isTestInvalid, setIsTestInvalid] = createSignal(false);
diff --git a/frontend/src/ts/test/out-of-focus.ts b/frontend/src/ts/test/out-of-focus.ts
deleted file mode 100644
index 4d8d77c42457..000000000000
--- a/frontend/src/ts/test/out-of-focus.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as Misc from "../utils/misc";
-import { Config } from "../config/store";
-import { qs, qsa } from "../utils/dom";
-import { setOutOfFocus } from "../states/test";
-
-const outOfFocusTimeouts: (number | NodeJS.Timeout)[] = [];
-
-const messages = {
- default: "Click here or press any key to focus",
- window: "Click anywhere to focus the window",
-};
-
-export function hide(): void {
- qsa("#words")?.setStyle({ transition: "none" })?.removeClass("blurred");
- setOutOfFocus(false);
- qs(".outOfFocusWarning")?.hide();
- Misc.clearTimeouts(outOfFocusTimeouts);
-}
-
-export function show(mode: "default" | "window" = "default"): void {
- if (!Config.showOutOfFocusWarning) return;
- outOfFocusTimeouts.push(
- setTimeout(() => {
- qsa("#words")?.setStyle({ transition: "0.25s" })?.addClass("blurred");
- setOutOfFocus(true);
- qs(".outOfFocusWarning .text")?.setText(messages[mode]);
- qs(".outOfFocusWarning")?.show();
- }, 1000),
- );
-}
diff --git a/frontend/src/ts/test/test-ui.ts b/frontend/src/ts/test/test-ui.ts
index ea6df1126d55..449ffa0ca468 100644
--- a/frontend/src/ts/test/test-ui.ts
+++ b/frontend/src/ts/test/test-ui.ts
@@ -10,7 +10,6 @@ import { getCurrentInput } from "./events/data";
import { getLiveCachedAccuracy } from "./events/live-cache";
import * as CustomText from "./custom-text";
import * as Caret from "./caret";
-import * as OutOfFocus from "./out-of-focus";
import * as Misc from "../utils/misc";
import * as Strings from "../utils/strings";
import { blendTwoHexColors } from "../utils/colors";
@@ -65,8 +64,12 @@ import {
isTestActive,
resetCurrentLiveStats,
setCompositionText,
+ setOutOfFocusMaxHeight,
wordsHaveNewline,
+ setTestFocusState,
+ showOutOfFocusWarning,
} from "../states/test";
+import { createEffect } from "solid-js";
import {
getCorrectedWordsHistory,
getInputHistory,
@@ -87,6 +90,16 @@ export let activeWordTop = 0;
export let activeWordHeight = 0;
let wordTopBeforeLineJump = 0;
let lineTransition = false;
+
+// #words is still vanilla; the warning itself is Solid (OutOfFocusWarning.tsx).
+// show/hideOutOfFocus live in states/test so commandline needn't import test-ui.
+createEffect(() => {
+ if (showOutOfFocusWarning()) {
+ wordsEl.setStyle({ transition: "0.25s" })?.addClass("blurred");
+ } else {
+ wordsEl.setStyle({ transition: "none" })?.removeClass("blurred");
+ }
+});
let currentTestLine = 0;
export function focusWords(force = false): void {
@@ -404,7 +417,7 @@ function buildWordHTML(word: string, wordIndex: number): string {
function updateWordWrapperClasses(): void {
// outoffocus applies transition, need to remove it
- OutOfFocus.hide();
+ setTestFocusState("focused");
if (Config.tapeMode !== "off") {
wordsEl.addClass("tape");
@@ -492,7 +505,7 @@ function updateWordWrapperClasses(): void {
Caret.updatePosition();
if (!isInputElementFocused()) {
- OutOfFocus.show();
+ setTestFocusState("unfocused");
}
}
@@ -607,9 +620,6 @@ export async function centerActiveLine(): Promise {
export function updateWordsWrapperHeight(force = false): void {
if (getActivePage() !== "test" || TestState.resultVisible) return;
if (!force && Config.mode !== "custom") return;
- const outOfFocusEl = document.querySelector(
- ".outOfFocusWarning",
- ) as HTMLElement;
const activeWordEl = getActiveWordElement();
if (!activeWordEl) return;
@@ -669,7 +679,7 @@ export function updateWordsWrapperHeight(force = false): void {
}
}
- outOfFocusEl.style.maxHeight = `${wordHeight * 3}px`;
+ setOutOfFocusMaxHeight(wordHeight * 3);
}
function updateWordsMargin(): void {
@@ -1918,7 +1928,7 @@ export function onTestFinish(): void {
LiveAcc.hide();
LiveBurst.hide();
TimerProgress.hide();
- OutOfFocus.hide();
+ setTestFocusState("focused");
if (Config.playSoundOnClick === "16") {
void SoundController.playFartReverb();
}
@@ -1982,14 +1992,14 @@ addEventListener("resize", () => {
qs("#wordsInput")?.on("focus", (e) => {
if (!isInputElementFocused()) return;
if (!TestState.resultVisible && Config.showOutOfFocusWarning) {
- OutOfFocus.hide();
+ setTestFocusState("focused");
}
Caret.show(true);
});
qs("#wordsInput")?.on("focusout", () => {
if (!isInputElementFocused()) {
- OutOfFocus.show();
+ setTestFocusState("unfocused");
}
Caret.hide();
});
@@ -2003,13 +2013,13 @@ qs("#wordsWrapper")?.on("click", () => {
});
window.addEventListener("blur", () => {
- OutOfFocus.show("window");
+ setTestFocusState("unfocusedWindow");
});
// little roadblock for basic cheating
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "hidden") return;
- OutOfFocus.show("window");
+ setTestFocusState("unfocusedWindow");
});
configEvent.subscribe(({ key, newValue }) => {
@@ -2023,7 +2033,7 @@ configEvent.subscribe(({ key, newValue }) => {
updateLiveStatsColor(newValue);
}
if (key === "showOutOfFocusWarning" && !newValue) {
- OutOfFocus.hide();
+ setTestFocusState("focused");
}
if (key === "compositionDisplay" && newValue === "below") {
setCompositionText(" ");