feat(funbox): add polyglot (@fehmer) (#6454)

Add polyglot funbox which let you practice on multiple languages at once
in a single test.

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-04-18 16:48:35 +02:00 committed by GitHub
parent a8ce609f0d
commit da671337c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 229 additions and 22 deletions

View file

@ -351,6 +351,19 @@
</div>
</div>
<div class="sectionSpacer"></div>
<div class="section" data-config-name="customPolyglot">
<div class="groupTitle">
<i class="fas fa-language"></i>
<span>polyglot languages</span>
</div>
<div class="text">
Select which languages you want the polyglot funbox to use.
</div>
<div class="inputs">
<select multiple></select>
</div>
</div>
<div class="sectionSpacer"></div>
</div>
<button id="group_input" class="text sectionGroupTitle" group="input">
<i class="fas fa-chevron-down"></i>

View file

@ -53,6 +53,7 @@ function addCommandlineBackground(): void {
type ShowSettings = {
subgroupOverride?: CommandsSubgroup | string;
commandOverride?: string;
singleListOverride?: boolean;
};
@ -102,6 +103,25 @@ export function show(
subgroupOverride = null;
usingSingleList = Config.singleListCommandLine === "on";
}
let showInputCommand: Command | undefined = undefined;
if (settings?.commandOverride !== undefined) {
const command = (await getList()).filter(
(c) => c.id === settings.commandOverride
)[0];
if (command === undefined) {
Notifications.add(`Command ${settings.commandOverride} not found`, 0);
} else if (command?.input !== true) {
Notifications.add(
`Command ${settings.commandOverride} is not an input command`,
0
);
} else {
showInputCommand = command;
}
}
if (settings?.singleListOverride) {
usingSingleList = settings.singleListOverride;
}
@ -114,6 +134,20 @@ export function show(
await updateActiveCommand();
setTimeout(() => {
keepActiveCommandInView();
if (showInputCommand) {
const escaped =
showInputCommand.display.split("</i>")[1] ??
showInputCommand.display;
mode = "input";
inputModeParams = {
command: showInputCommand,
placeholder: escaped,
value: showInputCommand.defaultValue?.() ?? "",
icon: showInputCommand.icon ?? "fa-chevron-right",
};
updateInput(inputModeParams.value as string);
hideCommands();
}
}, 1);
},
});

View file

@ -226,6 +226,19 @@ export const commands: CommandsSubgroup = {
UpdateConfig.setCustomLayoutfluid(input);
},
},
{
id: "changeCustomPolyglot",
display: "Polyglot languages...",
defaultValue: (): string => {
return Config.customPolyglot.join(" ");
},
input: true,
icon: "fa-language",
exec: ({ input }): void => {
if (input === undefined) return;
void UpdateConfig.setCustomPolyglot(input.split(" "));
},
},
//input
...FreedomModeCommands,

View file

@ -1906,6 +1906,26 @@ export function setCustomLayoutfluid(
return true;
}
export function setCustomPolyglot(
value: ConfigSchemas.CustomPolyglot,
nosave?: boolean
): boolean {
if (
!isConfigValueValid(
"customPolyglot",
value,
ConfigSchemas.CustomPolyglotSchema
)
)
return false;
config.customPolyglot = value;
saveToLocalStorage("customPolyglot", nosave);
ConfigEvent.dispatch("customPolyglot", config.customPolyglot);
return true;
}
export function setCustomBackgroundSize(
value: ConfigSchemas.CustomBackgroundSize,
nosave?: boolean
@ -2012,6 +2032,7 @@ export async function apply(
true
);
setCustomLayoutfluid(configObj.customLayoutfluid, true);
setCustomPolyglot(configObj.customPolyglot, true);
setCustomBackground(configObj.customBackground, true);
setCustomBackgroundSize(configObj.customBackgroundSize, true);
setCustomBackgroundFilter(configObj.customBackgroundFilter, true);

View file

@ -93,6 +93,7 @@ const obj = {
customBackgroundSize: "cover",
customBackgroundFilter: [0, 1, 1, 1],
customLayoutfluid: "qwerty#dvorak#colemak",
customPolyglot: ["english", "spanish", "french", "german"],
monkeyPowerLevel: "off",
minBurst: "off",
minBurstCustomSpeed: 100,

View file

@ -9,6 +9,7 @@ import { isAuthenticated } from "../firebase";
import * as CustomTextState from "../states/custom-text-name";
import { getLanguageDisplayString } from "../utils/strings";
import Format from "../utils/format";
import { getActiveFunboxNames } from "../test/funbox/list";
ConfigEvent.subscribe((eventKey) => {
if (
@ -25,6 +26,7 @@ ConfigEvent.subscribe((eventKey) => {
"showAverage",
"typingSpeedUnit",
"quickRestart",
"changeCustomPolyglot",
].includes(eventKey)
) {
void update();
@ -91,7 +93,9 @@ export async function update(): Promise<void> {
);
}
if (Config.mode !== "zen") {
const usingPolyglot = getActiveFunboxNames().includes("polyglot");
if (Config.mode !== "zen" && !usingPolyglot) {
$(".pageTest #testModesNotice").append(
`<button class="textButton" commands="languages"><i class="fas fa-globe-americas"></i>${getLanguageDisplayString(
Config.language,
@ -100,6 +104,19 @@ export async function update(): Promise<void> {
);
}
if (usingPolyglot) {
const languages = Config.customPolyglot
.map((lang) => {
const langDisplay = getLanguageDisplayString(lang, true);
return langDisplay;
})
.join(", ");
$(".pageTest #testModesNotice").append(
`<button class="textButton" commandId="changeCustomPolyglot"><i class="fas fa-globe-americas"></i>${languages}</button>`
);
}
if (Config.difficulty === "expert") {
$(".pageTest #testModesNotice").append(
`<button class="textButton" commands="difficulty"><i class="fas fa-star-half-alt"></i>expert</button>`

View file

@ -21,6 +21,12 @@ $(".pageTest").on("click", "#testModesNotice .textButton", async (event) => {
(await getCommandline()).show({ subgroupOverride: attr });
});
$(".pageTest").on("click", "#testModesNotice .textButton", async (event) => {
const attr = $(event.currentTarget).attr("commandId");
if (attr === undefined) return;
(await getCommandline()).show({ commandOverride: attr });
});
$(".pageTest").on("click", "#testConfig .wordCount .textButton", (e) => {
const wrd = $(e.currentTarget).attr("wordCount");
if (wrd === "custom") {

View file

@ -32,11 +32,12 @@ import {
import { getActiveFunboxNames } from "../test/funbox/list";
import { SnapshotPreset } from "../constants/default-snapshot";
import { LayoutsList } from "../constants/layouts";
import { DataArrayPartial } from "slim-select/store";
import { DataArrayPartial, Optgroup } from "slim-select/store";
type SettingsGroups<T extends ConfigValue> = Record<string, SettingsGroup<T>>;
let customLayoutFluidSelect: SlimSelect | undefined;
let customPolyglotSelect: SlimSelect | undefined;
export const groups: SettingsGroups<ConfigValue> = {};
@ -476,21 +477,12 @@ async function fillSettingsPage(): Promise<void> {
".pageSettings .section[data-config-name='language'] select"
) as Element;
let html = "";
if (languageGroups) {
for (const group of languageGroups) {
html += `<optgroup label="${group.name}">`;
for (const language of group.languages) {
const selected = language === Config.language ? "selected" : "";
const text = Strings.getLanguageDisplayString(language);
html += `<option value="${language}" ${selected}>${text}</option>`;
}
html += `</optgroup>`;
}
}
element.innerHTML = html;
new SlimSelect({
select: element,
data: getLanguageDropdownData(
languageGroups ?? [],
(language) => language === Config.language
),
settings: {
searchPlaceholder: "search",
},
@ -687,11 +679,11 @@ async function fillSettingsPage(): Promise<void> {
Config.keymapSize
);
const customLayoutfluidElement = document.querySelector(
".pageSettings .section[data-config-name='customLayoutfluid'] select"
) as Element;
if (customLayoutFluidSelect === undefined) {
const customLayoutfluidElement = document.querySelector(
".pageSettings .section[data-config-name='customLayoutfluid'] select"
) as Element;
customLayoutFluidSelect = new SlimSelect({
select: customLayoutfluidElement,
settings: { keepOrder: true },
@ -706,6 +698,27 @@ async function fillSettingsPage(): Promise<void> {
});
}
if (customPolyglotSelect === undefined) {
const customPolyglotElement = document.querySelector(
".pageSettings .section[data-config-name='customPolyglot'] select"
) as Element;
customPolyglotSelect = new SlimSelect({
select: customPolyglotElement,
data: getLanguageDropdownData(languageGroups ?? [], (language) =>
Config.customPolyglot.includes(language)
),
events: {
afterChange: (newVal): void => {
const customPolyglot = newVal.map((it) => it.value);
if (customPolyglot.toSorted() !== Config.customPolyglot.toSorted()) {
void UpdateConfig.setCustomPolyglot(customPolyglot);
}
},
},
});
}
$(".pageSettings .section[data-config-name='tapeMargin'] input").val(
Config.tapeMargin
);
@ -911,6 +924,13 @@ export async function update(groupUpdate = true): Promise<void> {
) {
customLayoutFluidSelect.setData(getLayoutfluidDropdownData());
}
if (
customPolyglotSelect !== undefined &&
customPolyglotSelect.getSelected() !== Config.customPolyglot
) {
customPolyglotSelect.setSelected(Config.customPolyglot);
}
}
function toggleSettingsGroup(groupName: string): void {
const groupEl = $(`.pageSettings .settingsGroup.${groupName}`);
@ -1359,6 +1379,23 @@ export function setEventDisabled(value: boolean): void {
configEventDisabled = value;
}
function getLanguageDropdownData(
languageGroups: JSONData.LanguageGroup[],
isActive: (val: string) => boolean
): DataArrayPartial {
return languageGroups.map(
(group) =>
({
label: group.name,
options: group.languages.map((language) => ({
text: Strings.getLanguageDisplayString(language),
value: language,
selected: isActive(language),
})),
} as Optgroup)
);
}
function getLayoutfluidDropdownData(): DataArrayPartial {
const customLayoutfluidActive = Config.customLayoutfluid.split("#");
return [

View file

@ -645,6 +645,16 @@ const list: Partial<Record<FunboxName, FunboxFunctions>> = {
return word.toUpperCase();
},
},
polyglot: {
async withWords(_words) {
const promises = Config.customPolyglot.map(JSONData.getLanguage);
const languages = await Promise.all(promises);
const wordSet = languages.flatMap((it) => it.words);
Arrays.shuffle(wordSet);
return new Wordset(wordSet);
},
},
};
export function getFunboxFunctions(): Record<FunboxName, FunboxFunctions> {

View file

@ -198,6 +198,9 @@ export type CustomBackgroundFilter = z.infer<
export const CustomLayoutFluidSchema = z.string().regex(/^[0-9a-zA-Z_#]+$/); //TODO better regex
export type CustomLayoutFluid = z.infer<typeof CustomLayoutFluidSchema>;
export const CustomPolyglotSchema = z.array(LanguageSchema).min(1);
export type CustomPolyglot = z.infer<typeof CustomPolyglotSchema>;
export const MonkeyPowerLevelSchema = z.enum(["off", "1", "2", "3", "4"]);
export type MonkeyPowerLevel = z.infer<typeof MonkeyPowerLevelSchema>;
@ -383,6 +386,7 @@ export const ConfigSchema = z
lazyMode: z.boolean(),
showAverage: ShowAverageSchema,
maxLineWidth: MaxLineWidthSchema,
customPolyglot: CustomPolyglotSchema,
} satisfies Record<string, ZodSchema>)
.strict();
@ -496,6 +500,7 @@ export const ConfigGroupsLiteral = {
lazyMode: "input",
showAverage: "hideElements",
maxLineWidth: "appearance",
customPolyglot: "behavior",
} as const satisfies Record<ConfigKey, ConfigGroupName>;
export type ConfigGroups = typeof ConfigGroupsLiteral;

View file

@ -114,5 +114,46 @@ describe("validation", () => {
true
);
});
describe("should validate two funboxes modifying the wordset", () => {
const testCases = [
{
firstFunction: "withWords",
secondFunction: "withWords",
compatible: false,
},
{
firstFunction: "withWords",
secondFunction: "getWord",
compatible: false,
},
{
firstFunction: "getWord",
secondFunction: "pullSection",
compatible: false,
},
];
it.for(testCases)(
`expect $firstFunction and $secondFunction to be compatible $compatible`,
({ firstFunction, secondFunction, compatible }) => {
//GIVEN
getFunboxMock.mockReturnValueOnce([
{
name: "plus_one",
frontendFunctions: [firstFunction],
} as FunboxMetadata,
{
name: "plus_two",
frontendFunctions: [secondFunction],
} as FunboxMetadata,
]);
//WHEN / THEN
expect(Validation.checkCompatibility(["plus_one", "plus_two"])).toBe(
compatible
);
}
);
});
});
});

View file

@ -444,6 +444,14 @@ const list: Record<FunboxName, FunboxMetadata> = {
frontendFunctions: ["alterText"],
name: "ALL_CAPS",
},
polyglot: {
description: "Use words from multiple languages in a single test.",
canGetPb: false,
difficultyLevel: 1,
properties: ["ignoresLanguage"],
frontendFunctions: ["withWords"],
name: "polyglot",
},
};
// oxlint doesnt understand ts overloading

View file

@ -41,7 +41,8 @@ export type FunboxName =
| "ddoouubblleedd"
| "instant_messaging"
| "underscore_spaces"
| "ALL_CAPS";
| "ALL_CAPS"
| "polyglot";
export type FunboxForcedConfig = Record<string, string[] | boolean[]>;

View file

@ -19,8 +19,8 @@ export function checkCompatibility(
const oneWordModifierMax =
funboxesToCheck.filter(
(f) =>
f.frontendFunctions?.includes("getWord") ??
f.frontendFunctions?.includes("pullSection") ??
f.frontendFunctions?.includes("getWord") ||
f.frontendFunctions?.includes("pullSection") ||
f.frontendFunctions?.includes("withWords")
).length <= 1;
const oneWordOrderMax =