fix(settings): List every installed font family in the font pickers (#225)

The font pickers never read the installed fonts: they probed a hard-coded list of ~30 names with canvas width checks, so anything outside the list never appeared and the monospaced filter changed nothing. A new ide-fonts crate asks the platform font system for families and their fixed-pitch flag (CoreText on macOS, DirectWrite on Windows, fc-list on Linux/BSD), exposed as system_font_families (spawn_blocking, cached). Every font picker — editor font and fallback, terminal font and fallback, UI custom font — uses one FontSelect with prefix type-ahead, a real monospaced filter, and keeps an uninstalled selection visible.

Not verified on Windows or Linux machines.

Fixes #199

Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 12:05:46 +08:00
1 джерело e537dd43a0
коміт aea0b33585
21 files changed
+647 -124

No files matched your search

+14
Переглянути файл
@@ -152,6 +152,11 @@ export const commands = {
* false when the clipboard stayed busy; the caller keeps working either way.
*/
clipboardSetFileList: (paths: string[]) => typedError<boolean, IdeError>(__TAURI_INVOKE("clipboard_set_file_list", { paths })),
/**
* Every installed family, sorted, each flagged monospaced or not. Read once
* per run (off the command threads) and cached.
*/
systemFontFamilies: () => typedError<FontFamily[], IdeError>(__TAURI_INVOKE("system_font_families")),
/**
* Opens a document. Async: reading and decoding a large file must not block
* the main thread (synchronous commands run on it).
@@ -2979,6 +2984,15 @@ export type FoldingSettings = {
docComments?: boolean,
};
/**
* One installed family. `monospaced` is the font system's own flag (fixed
* pitch; on Linux also fontconfig's dual-width CJK monospace).
*/
export type FontFamily = {
name: string,
monospaced: boolean,
};
export type FormatOptions = {
tabSize: number,
insertSpaces: boolean,
+108
Переглянути файл
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { FontFamily } from "@/platform/ipc";
const installed: FontFamily[] = [
{ name: "Andale Mono", monospaced: true },
{ name: "Arial", monospaced: false },
{ name: "Courier New", monospaced: true },
{ name: "Georgia", monospaced: false },
{ name: "Helvetica Neue", monospaced: false },
{ name: "Maple Mono NF CN", monospaced: true },
{ name: "Menlo", monospaced: true },
{ name: "Monaco", monospaced: true },
{ name: "PingFang SC", monospaced: false },
{ name: "Sarasa Term SC", monospaced: true },
];
const systemFontFamilies = vi.fn(async () => ({ status: "ok" as const, data: installed }));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return { ...real, commands: { ...real.commands, systemFontFamilies: () => systemFontFamilies() } };
});
import { resetSystemFontsForTests } from "@/platform/systemFonts";
import { FontSelect } from "../components/FontSelect";
afterEach(() => resetSystemFontsForTests());
async function open(ui: React.ReactElement, label: string) {
render(ui);
await act(async () => {});
fireEvent.click(screen.getByRole("button", { name: label }));
return screen.getAllByRole("option").map((o) => o.textContent);
}
describe("FontSelect", () => {
it("lists every installed family plus the bundled one, sorted", async () => {
const names = await open(<FontSelect value="Menlo" onChange={() => {}} aria-label="Font" />, "Font");
expect(names).toEqual([
"Andale Mono",
"Arial",
"Courier New",
"Georgia",
"Helvetica Neue",
"JetBrains Mono",
"Maple Mono NF CN",
"Menlo",
"Monaco",
"PingFang SC",
"Sarasa Term SC",
]);
});
it("filters to monospaced families when asked, keeping the current one", async () => {
const names = await open(
<FontSelect value="PingFang SC" onChange={() => {}} monospacedOnly aria-label="Font" />,
"Font",
);
expect(names).toEqual([
"Andale Mono",
"Courier New",
"JetBrains Mono",
"Maple Mono NF CN",
"Menlo",
"Monaco",
"PingFang SC",
"Sarasa Term SC",
]);
});
it("keeps a current family that isn't installed here", async () => {
render(<FontSelect value="Fira Code" onChange={() => {}} monospacedOnly aria-label="Font" />);
await act(async () => {});
expect(screen.getByRole("button", { name: "Font" })).toHaveTextContent("Fira Code");
});
it("offers None first for fallback fonts, without the bundled family", async () => {
const names = await open(
<FontSelect value="" onChange={() => {}} none aria-label="Fallback font" />,
"Fallback font",
);
expect(names[0]).toBe("None");
expect(names).not.toContain("JetBrains Mono");
expect(names).toHaveLength(installed.length + 1);
});
it("jumps to the family typed as a prefix", async () => {
const onChange = vi.fn();
await open(<FontSelect value="Arial" onChange={onChange} aria-label="Font" />, "Font");
const popup = document.querySelector("[data-autofocus]")!;
fireEvent.keyDown(popup, { key: "m", timeStamp: 1000 });
fireEvent.keyDown(popup, { key: "e", timeStamp: 1100 });
fireEvent.keyDown(popup, { key: "Enter", timeStamp: 1200 });
expect(onChange).toHaveBeenCalledWith("Menlo");
});
it("reads the font list once", async () => {
systemFontFamilies.mockClear();
render(
<>
<FontSelect value="Menlo" onChange={() => {}} aria-label="A" />
<FontSelect value="Menlo" onChange={() => {}} aria-label="B" />
</>,
);
await act(async () => {});
expect(systemFontFamilies).toHaveBeenCalledTimes(1);
});
});
+2 -2
Переглянути файл
@@ -6,6 +6,7 @@
* pressed only" and "Save files if the IDE is idle".
*/
import { executeAction, type Action } from "@/platform/actions";
import { uiFontFamily } from "@/platform/fonts";
import { call, commands, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { setDragRequiresAlt } from "@/ui";
@@ -32,8 +33,7 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc
if (size === DEFAULT_SETTINGS.appearance.uiFontSize) root.style.removeProperty(name);
else root.style.setProperty(name, `${size + delta}px`);
}
if (a.useCustomFont)
root.style.setProperty("--font-ui", `"${a.uiFontFamily.replace(/"/g, "")}", system-ui, sans-serif`);
if (a.useCustomFont) root.style.setProperty("--font-ui", uiFontFamily(a.uiFontFamily));
else root.style.removeProperty("--font-ui");
flag(root, "compact", a.compactMode);
+46
Переглянути файл
@@ -0,0 +1,46 @@
/**
* Font-family pickers over the families installed on this machine. The current
* value always stays listed, so a family that isn't installed here (a synced
* setting, an uninstalled font) still shows as selected.
*/
import { useMemo } from "react";
import type { SelectOption } from "@/ui";
import { BUNDLED_MONO_FAMILY } from "@/platform/fonts";
import { fontNames, useSystemFonts } from "@/platform/systemFonts";
import { Select } from "./Form";
const NO_BUNDLED: readonly string[] = [];
const MONO_BUNDLED: readonly string[] = [BUNDLED_MONO_FAMILY];
export interface FontSelectProps {
value: string;
onChange: (value: string) => void;
/** List only monospaced families (the current value stays). */
monospacedOnly?: boolean;
/** Offer "None" (empty value) first: fallback fonts. */
none?: boolean;
/** Families the app bundles, listed even when not installed. Default: the bundled monospace (none for fallbacks). */
bundled?: readonly string[];
disabled?: boolean;
"aria-label": string;
}
export function FontSelect({
value,
onChange,
monospacedOnly,
none,
bundled = none ? NO_BUNDLED : MONO_BUNDLED,
disabled,
...aria
}: FontSelectProps) {
const fonts = useSystemFonts();
const options = useMemo<SelectOption[]>(() => {
const names = fontNames(fonts, { monospacedOnly, include: [...bundled, value] });
const list = names.map((n) => ({ value: n, label: n }));
return none ? [{ value: "", label: "None" }, ...list] : list;
}, [fonts, monospacedOnly, none, bundled, value]);
return (
<Select value={value} options={options} onChange={onChange} disabled={disabled} aria-label={aria["aria-label"]} />
);
}
+5
Переглянути файл
@@ -23,6 +23,11 @@
}
/* "[x] Limit history to: [1000] rows" */
.fontFamily {
display: flex;
align-items: center;
gap: 8px;
}
.inlineRow {
display: flex;
align-items: center;
+6 -19
Переглянути файл
@@ -14,6 +14,8 @@ import { Checkbox, IconButton, Input, Popup, type SelectOption, T } from "@/ui";
import { NOTIFICATION_GROUPS } from "@/workbench/notificationStore";
import { ColorSwatches, NumberInput, Row, SearchLabel, Select, Toggle } from "../components/Form";
import { useLabelMatch, useSetting } from "../components/context";
import { FontSelect } from "../components/FontSelect";
import { BUNDLED_MONO_FAMILY, BUNDLED_UI_FAMILY } from "@/platform/fonts";
import {
ZOOM_LIMITS,
IDLE_LIMITS,
@@ -174,35 +176,20 @@ function ZoomRow() {
);
}
export const UI_FONTS = ["Inter", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Arial", "Verdana", "JetBrains Mono"];
const UI_BUNDLED = [BUNDLED_UI_FAMILY, BUNDLED_MONO_FAMILY];
function CustomFontRow() {
const [on, setOn] = useSetting<boolean>("appearance.useCustomFont");
const [family, setFamily] = useSetting<string>("appearance.uiFontFamily");
const [size, setSize] = useSetting<number>("appearance.uiFontSize");
const [draft, setDraft] = useState<string | null>(null);
const id = useId();
const matched = useLabelMatch("Use custom font");
return (
<div className={clsx(styles.fontRow, matched && styles.matched)}>
<Checkbox checked={on} onChange={setOn} label={<SearchLabel text="Use custom font:" />} />
<Input
list={`${id}-fonts`}
className={styles.fontInput}
aria-label="UI font"
disabled={!on}
value={draft ?? family}
onChange={(e) => {
setDraft(e.target.value);
if (e.target.value.trim()) setFamily(e.target.value.trim());
}}
onBlur={() => setDraft(null)}
/>
<datalist id={`${id}-fonts`}>
{UI_FONTS.map((f) => (
<option key={f} value={f} />
))}
</datalist>
<span className={styles.fontInput}>
<FontSelect value={family} onChange={setFamily} bundled={UI_BUNDLED} disabled={!on} aria-label="UI font" />
</span>
<label className={clsx(styles.sizeLabel, !on && styles.disabled)} htmlFor={`${id}-size`}>
<SearchLabel text="Size:" />
</label>
+4 -80
Переглянути файл
@@ -2,10 +2,11 @@
* Editor › Font: family (installed fonts, monospaced filter), size, line height,
* ligatures, Typography Settings (main / bold weight, fallback font), editable preview.
*/
import { useMemo, useState } from "react";
import { useState } from "react";
import { Checkbox, type SelectOption, T } from "@/ui";
import { monoFontFamily } from "@/platform/fonts";
import { NumberInput, Row, SearchLabel, Select } from "../../components/Form";
import { FontSelect } from "../../components/FontSelect";
import { useLabelMatch, usePageContext, useSetting } from "../../components/context";
import { FONT_WEIGHTS, fontWeightOf, fontWeightVars } from "@/platform/editorSettings";
import { LIMITS } from "../../model/schema";
@@ -13,88 +14,15 @@ import { editorPage } from "./kit";
import styles from "./Editor.module.css";
import { tx } from "@/platform/i18n";
const CANDIDATES = [
"JetBrains Mono",
"SF Mono",
"Menlo",
"Monaco",
"Fira Code",
"Fira Mono",
"Source Code Pro",
"Cascadia Code",
"Cascadia Mono",
"IBM Plex Mono",
"Hack",
"Inconsolata",
"Roboto Mono",
"Ubuntu Mono",
"DejaVu Sans Mono",
"Consolas",
"Courier New",
"PT Mono",
"Andale Mono",
"Victor Mono",
"Iosevka",
"Sarasa Mono SC",
"Noto Sans Mono",
"Noto Sans Mono CJK SC",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Helvetica Neue",
"Arial",
"Inter",
"Georgia",
];
interface FontInfo {
name: string;
mono: boolean;
}
let detected: FontInfo[] | null = null;
/** Installed candidates, detected by comparing glyph widths against generic fallbacks. */
export function installedFonts(): FontInfo[] {
if (detected) return detected;
const canvas = typeof document !== "undefined" ? document.createElement("canvas") : null;
const ctx = canvas?.getContext?.("2d");
if (!ctx) return (detected = CANDIDATES.map((name) => ({ name, mono: true })));
const width = (font: string, text: string) => {
ctx.font = `32px ${font}`;
return ctx.measureText(text).width;
};
const sample = "mmmmmmmmmmlli1WQ@";
const out: FontInfo[] = [];
for (const name of CANDIDATES) {
const quoted = `"${name}"`;
const present = ["monospace", "serif", "sans-serif"].some(
(base) => width(`${quoted}, ${base}`, sample) !== width(base, sample),
);
if (!present && name !== "JetBrains Mono") continue;
out.push({
name,
mono: Math.abs(width(`${quoted}, monospace`, "iiiiiiiiii") - width(`${quoted}, monospace`, "WWWWWWWWWW")) < 0.5,
});
}
return (detected = out);
}
function FontRow() {
const [family, setFamily] = useSetting<string>("editor.fontFamily");
const [monoOnly, setMonoOnly] = useState(true);
const matched = useLabelMatch("Show only monospaced fonts");
const options = useMemo<SelectOption[]>(() => {
const list = installedFonts().filter((f) => !monoOnly || f.mono);
const names = list.map((f) => f.name);
if (!names.includes(family)) names.unshift(family);
return names.map((n) => ({ value: n, label: n }));
}, [family, monoOnly]);
return (
<Row label="Font">
<span className={styles.fontFamily}>
<span className={styles.fontSelect}>
<Select value={family} options={options} onChange={setFamily} aria-label="Font" />
<FontSelect value={family} onChange={setFamily} monospacedOnly={monoOnly} aria-label="Font" />
</span>
<span className={matched ? styles.matched : undefined}>
<Checkbox
@@ -125,13 +53,9 @@ function SizeRow() {
function FallbackRow() {
const [fallback, setFallback] = useSetting<string>("editor.fallbackFont");
const options = useMemo<SelectOption[]>(
() => [{ value: "", label: "None" }, ...installedFonts().map((f) => ({ value: f.name, label: f.name }))],
[],
);
return (
<Row label="Fallback font" hint="For symbols not supported by the main font">
<Select value={fallback} options={options} onChange={setFallback} aria-label="Fallback font" />
<FontSelect value={fallback} onChange={setFallback} none aria-label="Fallback font" />
</Row>
);
}
+24 -19
Переглянути файл
@@ -14,6 +14,7 @@ import {
} from "@/platform/workflowSettings";
import { Button, Checkbox, IconButton, Input, Menu, type SelectOption, T } from "@/ui";
import { NumberInput, Row, SearchLabel, Section, Select } from "../components/Form";
import { FontSelect } from "../components/FontSelect";
import { useLabelMatch, useSetting } from "../components/context";
import { selectPage, setQuery } from "../model/staging";
import type { PageSpec } from "./spec";
@@ -231,8 +232,8 @@ function TerminalFont() {
const [fallback, setFallback] = useSetting<string>("terminal.fallbackFontFamily");
const [columnWidth, setColumnWidth] = useSetting<number>("terminal.columnWidth");
const matched = useLabelMatch("Use editor font");
const familyId = useId();
const fallbackId = useId();
const monoMatched = useLabelMatch("Show only monospaced fonts");
const [monoOnly, setMonoOnly] = useState(true);
return (
<>
<div className={clsx(styles.inlineRow, matched && styles.matched)}>
@@ -245,14 +246,24 @@ function TerminalFont() {
</span>
</div>
<Indented>
<Row label="Font" htmlFor={familyId}>
<Input
id={familyId}
className={styles.medium}
disabled={useEditor}
value={family}
onChange={(e) => e.target.value.trim() && setFamily(e.target.value)}
/>
<Row label="Font">
<span className={styles.fontFamily}>
<FontSelect
value={family}
onChange={setFamily}
monospacedOnly={monoOnly}
disabled={useEditor}
aria-label="Terminal font"
/>
<span className={monoMatched ? styles.matched : undefined}>
<Checkbox
checked={monoOnly}
onChange={setMonoOnly}
disabled={useEditor}
label={<SearchLabel text="Show only monospaced fonts" />}
/>
</span>
</span>
</Row>
<Row label="Size">
<NumberInput
@@ -274,14 +285,8 @@ function TerminalFont() {
aria-label="Terminal line height"
/>
</Row>
<Row label="Fallback font" htmlFor={fallbackId} hint="Used for characters the main font lacks.">
<Input
id={fallbackId}
className={styles.medium}
value={fallback}
placeholder="None"
onChange={(e) => setFallback(e.target.value)}
/>
<Row label="Fallback font" hint="Used for characters the main font lacks.">
<FontSelect value={fallback} onChange={setFallback} none aria-label="Terminal fallback font" />
</Row>
<Row label="Column width">
<NumberInput
@@ -588,7 +593,7 @@ export const TOOLS_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Use editor font",
keywords: ["Font", "Size", "Line height", "Fallback font", "Column width"],
keywords: ["Font", "Show only monospaced fonts", "Size", "Line height", "Fallback font", "Column width"],
component: TerminalFont,
},
],
+11 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { monoFontFamily } from "./fonts";
import { monoFontFamily, uiFontFamily } from "./fonts";
const BASE = "var(--font-mono)";
@@ -22,3 +22,13 @@ describe("monoFontFamily", () => {
);
});
});
describe("uiFontFamily", () => {
it.each([
["Inter", '"Inter Variable", "Inter", system-ui, sans-serif'],
["JetBrains Mono", '"JetBrains Mono Variable", "JetBrains Mono", system-ui, sans-serif'],
[' "PingFang SC" ', '"PingFang SC", system-ui, sans-serif'],
])("%j", (family, expected) => {
expect(uiFontFamily(family)).toBe(expected);
});
});
+14
Переглянути файл
@@ -30,6 +30,20 @@ export function monoFontFamily(family: string | undefined, fallback = "", base =
return [...head, ...(fb ? [quote(fb)] : []), base].join(", ");
}
export const BUNDLED_UI_FAMILY = "Inter";
const BUNDLED_UI_FACES = ['"Inter Variable"', '"Inter"'];
/**
* The `--font-ui` stack for a custom UI font. The bundled families map to their
* bundled faces, like `monoFontFamily`, so they render without a system install.
*/
export function uiFontFamily(family: string): string {
const name = family.replace(/"/g, "").trim();
const head =
name === BUNDLED_UI_FAMILY ? BUNDLED_UI_FACES : name === BUNDLED_MONO_FAMILY ? BUNDLED_MONO_FACES : [quote(name)];
return [...head, "system-ui", "sans-serif"].join(", ");
}
/**
* Resolves once the faces `family` uses at `size` are loaded, so code that measures
* glyphs (xterm's cell size) measures the real font rather than the swap fallback.
+2
Переглянути файл
@@ -2038,6 +2038,8 @@
"Template name": "模板名称",
"Template text": "模板文本",
"Terminal": "终端",
"Terminal fallback font": "终端备用字体",
"Terminal font": "终端字体",
"Terminal font size": "终端字体大小",
"Terminal line height": "终端行高",
"Terminal sessions": "终端会话",
+32
Переглянути файл
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { fontNames } from "./systemFonts";
const fonts = [
{ name: "menlo", monospaced: true },
{ name: "Arial", monospaced: false },
{ name: "Courier New", monospaced: true },
{ name: "Ångström Sans", monospaced: false },
];
describe("fontNames", () => {
it("sorts installed families case- and accent-insensitively", () => {
expect(fontNames(fonts)).toEqual(["Ångström Sans", "Arial", "Courier New", "menlo"]);
});
it("filters to monospaced families", () => {
expect(fontNames(fonts, { monospacedOnly: true })).toEqual(["Courier New", "menlo"]);
});
it("always includes the extra names, spelled as given, without duplicates", () => {
expect(fontNames(fonts, { monospacedOnly: true, include: ["JetBrains Mono", "Menlo", "Arial", " "] })).toEqual([
"Arial",
"Courier New",
"JetBrains Mono",
"Menlo",
]);
});
it("keeps the current family when nothing is installed", () => {
expect(fontNames([], { include: ["Fira Code"] })).toEqual(["Fira Code"]);
});
});
+57
Переглянути файл
@@ -0,0 +1,57 @@
/**
* The font families installed on this machine (`system_font_families`: CoreText,
* DirectWrite or fontconfig), for every font picker. Read once per window; the
* backend caches the list for the run.
*/
import { useEffect, useState } from "react";
import { call, commands, type FontFamily } from "./ipc";
let cached: FontFamily[] | null = null;
let pending: Promise<FontFamily[]> | null = null;
/** The installed families; empty when the font system can't be read. */
export function loadSystemFonts(): Promise<FontFamily[]> {
pending ??= call(commands.systemFontFamilies())
.catch(() => [] as FontFamily[])
.then((list) => (cached = list));
return pending;
}
/** The installed families, empty until the first read resolves. */
export function useSystemFonts(): FontFamily[] {
const [fonts, setFonts] = useState<FontFamily[]>(() => cached ?? []);
useEffect(() => {
if (cached) return;
let live = true;
void loadSystemFonts().then((list) => live && setFonts(list));
return () => {
live = false;
};
}, []);
return fonts;
}
/**
* Picker entries: the installed families (only monospaced ones when asked),
* plus `include` (the bundled faces, and the current value so a family that
* isn't installed here still shows as selected), deduplicated and sorted.
*/
export function fontNames(
fonts: readonly FontFamily[],
{ monospacedOnly = false, include = [] }: { monospacedOnly?: boolean; include?: readonly string[] } = {},
): string[] {
const seen = new Map<string, string>();
const add = (name: string) => {
const n = name.trim();
if (n && !seen.has(n.toLowerCase())) seen.set(n.toLowerCase(), n);
};
include.forEach(add);
for (const f of fonts) if (!monospacedOnly || f.monospaced) add(f.name);
return [...seen.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
}
/** Test hook: forget the cached list. */
export function resetSystemFontsForTests(): void {
cached = null;
pending = null;
}
+11 -3
Переглянути файл
@@ -28,6 +28,8 @@ export interface SelectProps<V extends string = string> {
renderValue?: (option: SelectOption<V> | undefined) => ReactNode;
}
const TYPE_AHEAD_MS = 800;
/** Dropdown select with a listbox popup (↑↓ Enter Esc, type-ahead). */
export function Select<V extends string = string>({
value,
@@ -45,6 +47,7 @@ export function Select<V extends string = string>({
const triggerRef = useRef<HTMLButtonElement>(null);
const current = options.find((o) => o.value === value);
const [active, setActive] = useState(0);
const typeAhead = useRef({ text: "", at: -Infinity });
const isSection = (o: SelectOption<V>) => !!o.disabled;
const openPopup = () => {
@@ -76,11 +79,16 @@ export function Select<V extends string = string>({
return;
}
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) {
const ch = e.key.toLowerCase();
// Keys typed in quick succession extend the prefix ("me" → Menlo); a lone key cycles.
const ahead = typeAhead.current;
ahead.text = e.timeStamp - ahead.at < TYPE_AHEAD_MS ? ahead.text + e.key.toLowerCase() : e.key.toLowerCase();
ahead.at = e.timeStamp;
const repeat = ahead.text.length > 1 && [...ahead.text].every((c) => c === ahead.text[0]);
const prefix = repeat ? ahead.text[0]! : ahead.text;
const n = options.length;
for (let s = 1; s <= n; s++) {
for (let s = prefix.length > 1 ? 0 : 1; s <= n; s++) {
const i = (active + s) % n;
if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(ch)) {
if (!options[i]!.disabled && tx(options[i]!.label).toLowerCase().startsWith(prefix)) {
setActive(i);
break;
}