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
14 changes: 0 additions & 14 deletions app/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -325,20 +325,6 @@ select:focus {
.modal select {
width: 100%;
}
/* The native number-input spinner (up/down arrows) renders as unstyled
* browser chrome that doesn't pick up the app's dark theme — hide it.
* (This field also sets step="any" for free decimal typing, so keyboard
* ArrowUp/ArrowDown stepping is UA-dependent regardless of this rule —
* typing/pasting a value is the guaranteed path either way.) */
.modal input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
.modal input[type="number"]::-webkit-inner-spin-button,
.modal input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.path-row {
display: flex;
gap: 6px;
Expand Down
69 changes: 68 additions & 1 deletion app/src/modals.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { shouldCloseOnEscape } from "./modals";
import { sanitizeNumberDraft, shouldCloseOnEscape, stepFontSize } from "./modals";

function esc(overrides: Partial<{ isComposing: boolean; keyCode: number; defaultPrevented: boolean }> = {}) {
return {
Expand Down Expand Up @@ -32,3 +32,70 @@ describe("shouldCloseOnEscape", () => {
expect(shouldCloseOnEscape(esc({ defaultPrevented: true }))).toBe(false);
});
});

describe("sanitizeNumberDraft", () => {
it("passes plain digits through unchanged", () => {
expect(sanitizeNumberDraft("12")).toBe("12");
});

it("strips letters and symbols WKWebView can let slip into a number input", () => {
expect(sanitizeNumberDraft("1a2b")).toBe("12");
expect(sanitizeNumberDraft("1e5")).toBe("15");
expect(sanitizeNumberDraft("!@#12$%")).toBe("12");
});

it("keeps a single leading minus sign", () => {
expect(sanitizeNumberDraft("-12")).toBe("-12");
});

it("drops a minus sign anywhere but the first character", () => {
expect(sanitizeNumberDraft("1-2")).toBe("12");
expect(sanitizeNumberDraft("12-")).toBe("12");
expect(sanitizeNumberDraft("--12")).toBe("-12");
});

it("keeps only the first decimal point", () => {
expect(sanitizeNumberDraft("1.2.3")).toBe("1.23");
expect(sanitizeNumberDraft("1..2")).toBe("1.2");
});

it("allows a bare decimal point mid-edit (e.g. typing '12.' before the fraction)", () => {
expect(sanitizeNumberDraft("12.")).toBe("12.");
});

it("returns an empty string for entirely non-numeric input", () => {
expect(sanitizeNumberDraft("abc")).toBe("");
});

it("passes an already-empty string through unchanged", () => {
expect(sanitizeNumberDraft("")).toBe("");
});
});

describe("stepFontSize", () => {
it("steps up by 1 from a valid draft", () => {
expect(stepFontSize("12", 12, 1, 8, 24)).toBe(13);
});

it("steps down by 1 from a valid draft", () => {
expect(stepFontSize("12", 12, -1, 8, 24)).toBe(11);
});

it("falls back to the committed value when the draft doesn't parse (e.g. empty, mid-edit)", () => {
expect(stepFontSize("", 12, 1, 8, 24)).toBe(13);
expect(stepFontSize("-", 12, 1, 8, 24)).toBe(13);
expect(stepFontSize(".", 12, -1, 8, 24)).toBe(11);
});

it("clamps at the maximum", () => {
expect(stepFontSize("24", 24, 1, 8, 24)).toBe(24);
});

it("clamps at the minimum", () => {
expect(stepFontSize("8", 8, -1, 8, 24)).toBe(8);
});

it("steps from a decimal draft and can land on a non-integer", () => {
expect(stepFontSize("12.5", 12.5, 1, 8, 24)).toBe(13.5);
});
});
118 changes: 109 additions & 9 deletions app/src/modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,48 @@ export function ConfirmModal(props: {
export const MIN_TERMINAL_FONT_SIZE = 8;
export const MAX_TERMINAL_FONT_SIZE = 24;

// type="number" doesn't reliably block non-numeric characters in every
// webview engine (koit's real-hardware report: WKWebView on macOS let them
// through into the field). Strips anything that isn't a digit, a leading
// "-", or the first "." — applied to the DRAFT text itself before it's
// shown, not just before committing, so a rejected character never
// visibly lands in the field even for a frame.
export function sanitizeNumberDraft(raw: string): string {
let result = "";
let seenDot = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (ch === "-" && i === 0) {
result += ch;
} else if (ch === "." && !seenDot) {
seenDot = true;
result += ch;
} else if (ch >= "0" && ch <= "9") {
result += ch;
}
}
return result;
}

// ArrowUp/ArrowDown stepping for the font-size field, extracted as a pure
// function so its clamping/fallback logic is unit-testable independent of
// the composition-guarded DOM event handler that calls it (see the
// onKeyDown below — the IME-composition check itself isn't something this
// helper can or should own).
export function stepFontSize(
draftText: string,
fallback: number,
direction: 1 | -1,
min: number,
max: number,
): number {
// Number("") is 0, not NaN — without the trim/empty check an empty draft
// would step from 0 instead of falling back to the last committed value.
const current = draftText.trim() === "" ? NaN : Number(draftText);
const base = Number.isFinite(current) ? current : fallback;
return Math.min(max, Math.max(min, base + direction));
}

export function SettingsModal(props: {
onClose: () => void;
terminalFontSize: number;
Expand All @@ -415,6 +457,17 @@ export function SettingsModal(props: {
// field). Free typing (including decimals, an empty field mid-edit) is
// always shown; only a complete, valid, in-range value is committed.
const [fontSizeText, setFontSizeText] = useState(() => String(props.terminalFontSize));
// A ref, not state — read/written synchronously inside the same tick as
// composition/change events, no re-render needed for it on its own.
const isComposingFontSize = useRef(false);
const commitFontSizeText = (raw: string) => {
const text = sanitizeNumberDraft(raw);
setFontSizeText(text);
const n = Number(text);
if (text.trim() !== "" && Number.isFinite(n) && n >= MIN_TERMINAL_FONT_SIZE && n <= MAX_TERMINAL_FONT_SIZE) {
props.onTerminalFontSizeChange(n);
}
};
// Computed once per modal open, not on every keystroke — the full zone
// list (400+ IANA names) doesn't change while the dropdown is open.
const [timeZones] = useState(listTimeZones);
Expand Down Expand Up @@ -461,18 +514,65 @@ export function SettingsModal(props: {
<label>
{t("settings.terminalFontSize.label")}
<input
type="number"
step="any"
min={MIN_TERMINAL_FONT_SIZE}
max={MAX_TERMINAL_FONT_SIZE}
// Not type="number": per spec, a number input's .value is forced
// to "" the instant its content doesn't parse as a number (the
// "bad input" state) — but the browser can keep showing the
// invalid text it actually rendered regardless, and a React
// controlled re-render doesn't reliably win against that native
// display state. inputMode="decimal" still hints a numeric
// keyboard on touch without any of that native sanitization
// fighting our own (sanitizeNumberDraft + the range check below
// already do full validation manually).
type="text"
inputMode="decimal"
value={fontSizeText}
onChange={(e) => {
const text = e.target.value;
setFontSizeText(text);
const n = Number(text);
if (text.trim() !== "" && Number.isFinite(n) && n >= MIN_TERMINAL_FONT_SIZE && n <= MAX_TERMINAL_FONT_SIZE) {
props.onTerminalFontSizeChange(n);
// While an IME composition is in progress (e.g. typing romaji
// that's live-converting to hiragana), the browser owns the
// field's displayed text and won't let a controlled re-render
// override it — sanitizing here would have no visible effect
// until composition ends anyway, so just mirror it as-is and
// let onCompositionEnd below do the real filtering once there's
// a final value to filter.
if (isComposingFontSize.current) {
setFontSizeText(e.target.value);
return;
}
commitFontSizeText(e.target.value);
}}
onCompositionStart={() => {
isComposingFontSize.current = true;
}}
onCompositionEnd={(e) => {
isComposingFontSize.current = false;
commitFontSizeText(e.currentTarget.value);
}}
onBlur={(e) => {
// Defensive catch-all: if focus leaves the field while a
// composition is somehow still open (or any other path
// resulted in unsanitized text reaching fontSizeText),
// losing focus is the last chance to clean it up.
isComposingFontSize.current = false;
commitFontSizeText(e.currentTarget.value);
}}
onKeyDown={(e) => {
// type="number" gave ArrowUp/ArrowDown stepping for free;
// type="text" doesn't, so re-implement it (step 1, clamped).
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
// Never hijack IME candidate navigation — during composition,
// ArrowUp/ArrowDown move the IME's own conversion-candidate
// selection, not this field's value. isComposingFontSize.current
// and e.nativeEvent.isComposing cover the standard case;
// keyCode 229 is the legacy fallback some engines still report
// for a composition keydown where isComposing isn't reliably set.
if (isComposingFontSize.current || e.nativeEvent.isComposing || e.keyCode === 229) return;
e.preventDefault();
const direction = e.key === "ArrowUp" ? 1 : -1;
commitFontSizeText(
String(
stepFontSize(fontSizeText, props.terminalFontSize, direction, MIN_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE),
),
);
}}
/>
</label>
Expand Down
Loading