diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index dd1ccb4f2d7..b3782ce4fb5 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -29,6 +29,7 @@ import { hasBoundedCommentBodyText } from '@/lib/comment-body-submit-state' import { createBrowserUuid } from '@/lib/browser-uuid' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useAppStore } from '@/store' import { jiraAddIssueComment, @@ -609,7 +610,7 @@ export default function JiraIssueWorkspace({ value={titleDraft} onChange={(event) => setTitleDraft(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) { event.preventDefault() handleSaveTitle() } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5c30fa545d0..565198fa132 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -42,6 +42,7 @@ import { useAllWorktrees, useRepoMap } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { getSettingsFocusedExecutionHostId, parseExecutionHostId, @@ -6482,12 +6483,7 @@ export default function TaskPage(): React.JSX.Element { (event: React.KeyboardEvent): void => { if (event.key === 'Enter') { // React SyntheticEvent does not expose isComposing; use nativeEvent. - if ( - shouldSuppressEnterSubmit( - { isComposing: event.nativeEvent.isComposing, shiftKey: event.shiftKey }, - false - ) - ) { + if (shouldSuppressEnterSubmit(event.nativeEvent, false)) { return } event.preventDefault() @@ -8622,15 +8618,7 @@ export default function TaskPage(): React.JSX.Element { onChange={(e) => setLinearSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { - if ( - shouldSuppressEnterSubmit( - { - isComposing: e.nativeEvent.isComposing, - shiftKey: e.shiftKey - }, - false - ) - ) { + if (shouldSuppressEnterSubmit(e.nativeEvent, false)) { return } e.preventDefault() @@ -8806,12 +8794,7 @@ export default function TaskPage(): React.JSX.Element { onChange={(e) => setJiraSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { - if ( - shouldSuppressEnterSubmit( - { isComposing: e.nativeEvent.isComposing, shiftKey: e.shiftKey }, - false - ) - ) { + if (shouldSuppressEnterSubmit(e.nativeEvent, false)) { return } e.preventDefault() @@ -11034,7 +11017,7 @@ export default function TaskPage(): React.JSX.Element { value={newIssueTitle} onChange={(e) => setNewIssueTitle(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) { e.preventDefault() void handleCreateNewIssue() } @@ -11208,7 +11191,7 @@ export default function TaskPage(): React.JSX.Element { value={newLinearProjectName} onChange={(event) => setNewLinearProjectName(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) { event.preventDefault() void handleCreateNewLinearProject() } @@ -11655,7 +11638,7 @@ export default function TaskPage(): React.JSX.Element { value={newLinearIssueTitle} onChange={(e) => setNewLinearIssueTitle(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) { e.preventDefault() void handleCreateNewLinearIssue() } @@ -12255,7 +12238,7 @@ export default function TaskPage(): React.JSX.Element { value={newJiraIssueTitle} onChange={(e) => setNewJiraIssueTitle(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) { e.preventDefault() void handleCreateNewJiraIssue() } diff --git a/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx b/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx index 28b8c7ef167..27ea5fa6878 100644 --- a/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx +++ b/src/renderer/src/components/browser-pane/markup/MarkupOverlay.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react' import { Check, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import { MarkupToolbar } from './MarkupToolbar' import type { MarkupBaseImage } from './markup-base-image' @@ -83,7 +84,7 @@ export function MarkupOverlay({ event.stopPropagation() // Why: during IME composition (e.g. Japanese conversion), Enter // confirms the candidate — it must NOT also commit the annotation. - if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) { event.preventDefault() editor.commitPendingText(event.currentTarget.value) } else if (event.key === 'Escape') { diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx index 9e7ddf229ce..f8ef8c15609 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx @@ -2,6 +2,7 @@ import { CornerDownLeft, Pencil, Trash } from 'lucide-react' import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' @@ -292,7 +293,7 @@ export function DiffCommentCard({ handleCancel() return } - if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) { + if (e.key === 'Enter' && !isImeCompositionKeyDown(e) && !e.shiftKey) { e.preventDefault() if (!canSubmit) { return diff --git a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx index 235c572216f..0074cc2ac0c 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from import { CornerDownLeft } from 'lucide-react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useMountedRef } from '@/hooks/useMountedRef' import { getCommentBodySubmitState, @@ -214,7 +215,7 @@ export function DiffCommentPopover({ return } // Why: Shift+Enter inserts a newline; skip isComposing so IME composition Enter doesn't submit a half-typed CJK note. - if (e.key === 'Enter' && !e.nativeEvent.isComposing && !e.shiftKey) { + if (e.key === 'Enter' && !isImeCompositionKeyDown(e) && !e.shiftKey) { e.preventDefault() if (submitting) { return diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index fe73dc07858..88207f36981 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -41,6 +41,7 @@ import { getConnectionIdForFile } from '@/lib/connection-context' import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' import { fileUrlToAbsolutePath, @@ -2120,7 +2121,7 @@ function MarkdownAnnotationComposer({ onCancel() return } - if (event.key === 'Enter' && !event.nativeEvent.isComposing && !event.shiftKey) { + if (event.key === 'Enter' && !isImeCompositionKeyDown(event) && !event.shiftKey) { event.preventDefault() void submit() } diff --git a/src/renderer/src/components/emulator-pane/use-emulator-screen-keyboard.ts b/src/renderer/src/components/emulator-pane/use-emulator-screen-keyboard.ts index 1eac52e1c2a..15d372ae9a5 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-screen-keyboard.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-screen-keyboard.ts @@ -8,6 +8,7 @@ import { } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { buildServeSimKeyboardFramesForKey, type ServeSimKeyboardFrame @@ -70,7 +71,7 @@ export function useEmulatorScreenKeyboard({ (event: KeyboardEvent) => { if ( !canInteract || - event.nativeEvent.isComposing || + isImeCompositionKeyDown(event) || event.metaKey || event.ctrlKey || event.altKey diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx index 39f43539ee9..00a39f71c7c 100644 --- a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -24,6 +24,7 @@ import { import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import GitHubItemDialog, { type GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' import { GhAuthErrorHelp } from '@/components/github-project/GhAuthErrorHelp' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' import { useRepoSlugIndex } from '@/lib/repo-slug-index' import { cn } from '@/lib/utils' @@ -1103,7 +1104,7 @@ function ProjectSearchInput({ onChange={(e) => setValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { - if (e.nativeEvent.isComposing) { + if (isImeCompositionKeyDown(e)) { return } e.preventDefault() diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index d287ab4fdce..986b1fb61dc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -1,4 +1,5 @@ import { useCallback, type Dispatch, type KeyboardEventHandler, type SetStateAction } from 'react' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { recallNext, recallPrevious, @@ -42,7 +43,7 @@ export function useNativeChatComposerKeyDown({ }: UseNativeChatComposerKeyDownArgs): KeyboardEventHandler { return useCallback( (event) => { - if (isComposing() || event.nativeEvent.isComposing || event.keyCode === 229) { + if (isComposing() || isImeCompositionKeyDown(event)) { // Why: IME Enter confirms composition; allowing it to fall through // would accept a picker row or submit a partial draft. if (event.key === 'Enter') { diff --git a/src/renderer/src/components/network/AddressPicker.tsx b/src/renderer/src/components/network/AddressPicker.tsx index e2ecb5ad8b0..119bfe0d605 100644 --- a/src/renderer/src/components/network/AddressPicker.tsx +++ b/src/renderer/src/components/network/AddressPicker.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, Plus, X } from 'lucide-react' import { cn } from '@/lib/utils' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { Button } from '../ui/button' import { Command, CommandGroup, CommandItem, CommandList } from '../ui/command' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' @@ -217,7 +218,7 @@ export function AddressPicker({ event.altKey || event.ctrlKey || event.metaKey || - event.nativeEvent.isComposing + isImeCompositionKeyDown(event) ) { return } diff --git a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts index f0935b625a0..cb453299a48 100644 --- a/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts +++ b/src/renderer/src/components/right-sidebar/useFileSearchPanel.ts @@ -1,5 +1,6 @@ import type React from 'react' import { useCallback, useDeferredValue, useEffect, useMemo, useRef } from 'react' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import type { SearchFileResult, SearchMatch, SearchResult } from '../../../../shared/types' @@ -206,7 +207,7 @@ export function useFileSearchPanel(explorerView: 'files' | 'search'): FileSearch const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.nativeEvent.isComposing) { + if (isImeCompositionKeyDown(e)) { return } if (e.key === 'Escape') { diff --git a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx index b6eebbeb1cf..0ac8effb189 100644 --- a/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCloneStep.tsx @@ -3,6 +3,7 @@ import { Folder } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' import { RemoteFileBrowser } from './RemoteFileBrowser' @@ -42,7 +43,7 @@ export function CloneStep({ const canBrowseRemoteDestination = isRemoteClone const canClone = !!cloneUrl.trim() && !!cloneDestination.trim() && !isCloning const handleKeyDown = (e: React.KeyboardEvent): void => { - if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + if (e.key === 'Enter' && !isImeCompositionKeyDown(e)) { e.preventDefault() if (canClone) { onClone() diff --git a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx index e8e5f816769..64d252d21b7 100644 --- a/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoRemoteStep.tsx @@ -3,6 +3,7 @@ import { CircleStop, FolderOpen, Settings } from 'lucide-react' import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import type { SshConnectionState, SshTarget } from '../../../../shared/ssh-types' import { RemoteFileBrowser } from './RemoteFileBrowser' import { SshTargetRow } from './SshTargetRow' @@ -172,7 +173,7 @@ export function RemoteStep({ value={remotePath} onChange={(event) => onRemotePathChange(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + if (event.key === 'Enter' && !isImeCompositionKeyDown(event)) { event.preventDefault() if (selectedTargetId && remotePath.trim() && !isAddingRemote) { onAdd() diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx index d55b9d084f8..0518d889603 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanSearchField.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react' import { Search, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' const ANNOUNCE_DEBOUNCE_MS = 400 @@ -119,7 +120,7 @@ export default function WorkspaceKanbanSearchField({ // this field is the only handler — it must cover both outcomes. // Mid-composition Escape belongs to the IME, which cancels the // in-progress reading rather than the query behind it. - if (event.key !== 'Escape' || event.nativeEvent.isComposing) { + if (event.key !== 'Escape' || isImeCompositionKeyDown(event)) { return } event.preventDefault() diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts index daa02c5411e..e6618e85d01 100644 --- a/src/renderer/src/lib/ime-composition-keyboard-event.test.ts +++ b/src/renderer/src/lib/ime-composition-keyboard-event.test.ts @@ -1,16 +1,34 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' import type { KeyboardEvent as ReactKeyboardEvent } from 'react' -import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' +import { + _setDocumentImeCompositionActiveForTests, + isDocumentImeCompositionActive, + isImeCompositionKeyDown +} from './ime-composition-keyboard-event' -function keyEvent(nativeEvent: { isComposing?: boolean; keyCode?: number }): ReactKeyboardEvent { +function keyEvent(nativeEvent: { + isComposing?: boolean + key?: string + keyCode?: number +}): ReactKeyboardEvent { return { nativeEvent: { isComposing: nativeEvent.isComposing ?? false, + key: nativeEvent.key ?? 'Enter', keyCode: nativeEvent.keyCode ?? 13 } } as unknown as ReactKeyboardEvent } +function dispatchOnBody(type: string): void { + document.body.dispatchEvent(new CompositionEvent(type, { bubbles: true, data: '' })) +} + +afterEach(() => { + _setDocumentImeCompositionActiveForTests(false) +}) + describe('isImeCompositionKeyDown', () => { it('is true while the IME is composing', () => { expect(isImeCompositionKeyDown(keyEvent({ isComposing: true }))).toBe(true) @@ -20,7 +38,89 @@ describe('isImeCompositionKeyDown', () => { expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) }) + it('is true for the Process key some engines report instead of 229', () => { + expect(isImeCompositionKeyDown(keyEvent({ key: 'Process', keyCode: 0 }))).toBe(true) + }) + it('is false for a plain Enter outside of composition', () => { expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) }) + + it.each([ + ['ArrowDown', 40], + ['Backspace', 8], + ['Escape', 27], + ['a', 65] + ])('leaves a plain %s outside composition alone', (key, keyCode) => { + expect(isImeCompositionKeyDown(keyEvent({ key, keyCode }))).toBe(false) + }) + + it('accepts a native KeyboardEvent as well as a React synthetic event', () => { + const native = new KeyboardEvent('keydown', { isComposing: true, key: 'Enter' }) + + expect(isImeCompositionKeyDown(native)).toBe(true) + }) + + it('falls back to the document flag when handed no event', () => { + _setDocumentImeCompositionActiveForTests(true) + + expect(isImeCompositionKeyDown(null)).toBe(true) + expect(isImeCompositionKeyDown(undefined)).toBe(true) + }) +}) + +describe('document composition tracking', () => { + it('suppresses the extra keydown Safari emits after compositionend', () => { + // compositionend has already cleared the flag by the time this arrives, so the + // numeric 229 marker is the only bit left that can catch it. + dispatchOnBody('compositionstart') + dispatchOnBody('compositionend') + expect(isDocumentImeCompositionActive()).toBe(false) + + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('suppresses a key that arrives mid-composition carrying no marker of its own', () => { + dispatchOnBody('compositionstart') + + expect(isImeCompositionKeyDown(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(true) + }) + + it('tracks composition anywhere in the document', () => { + dispatchOnBody('compositionstart') + expect(isDocumentImeCompositionActive()).toBe(true) + + dispatchOnBody('compositionend') + expect(isDocumentImeCompositionActive()).toBe(false) + }) + + it('clears the flag on focus loss because compositionend may never arrive', () => { + dispatchOnBody('compositionstart') + expect(isDocumentImeCompositionActive()).toBe(true) + + document.body.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + + expect(isDocumentImeCompositionActive()).toBe(false) + }) + + it('does not leave Enter permanently suppressed after an abandoned composition', () => { + dispatchOnBody('compositionstart') + document.body.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) + + expect(isImeCompositionKeyDown(keyEvent({ keyCode: 13 }))).toBe(false) + }) + + it('clears the flag when the composing element unmounts without focusout', () => { + // Chromium fires no focusout when a focused node is removed, so a composer + // that unmounts mid-composition would otherwise latch this on for good. + const input = document.createElement('input') + document.body.appendChild(input) + input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })) + expect(isDocumentImeCompositionActive()).toBe(true) + + input.remove() + + expect(isDocumentImeCompositionActive()).toBe(false) + expect(isImeCompositionKeyDown(keyEvent({ keyCode: 13 }))).toBe(false) + }) }) diff --git a/src/renderer/src/lib/ime-composition-keyboard-event.ts b/src/renderer/src/lib/ime-composition-keyboard-event.ts index c5faed78d16..393952250f6 100644 --- a/src/renderer/src/lib/ime-composition-keyboard-event.ts +++ b/src/renderer/src/lib/ime-composition-keyboard-event.ts @@ -1,13 +1,104 @@ import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +// The one place that decides a keystroke belongs to an IME. +// See the IME Composition rules in AGENTS.md before changing any of this. + +type ImeCandidateKeyboardEvent = Pick + +type AnyKeyboardEvent = + | ImeCandidateKeyboardEvent + | Pick + | null + | undefined + +function toNativeKeyboardEvent(event: AnyKeyboardEvent): ImeCandidateKeyboardEvent | null { + if (!event) { + return null + } + if ('nativeEvent' in event) { + return (event.nativeEvent as ImeCandidateKeyboardEvent | undefined) ?? null + } + return event +} + +// Why: browsers do not reliably deliver compositionend when the composing element +// loses focus or leaves the DOM, so the target is kept rather than a bare boolean. Focus loss clears it; unmounting cannot be observed +// that way, because Chromium fires no focusout when a focused node is removed — +// so a live read also revalidates that the target is still connected. Without +// that, one unmount mid-composition would latch this flag on for the session and +// silently kill every Enter-to-commit surface in the app. +let compositionTarget: Node | null = null +let compositionActiveWithoutTarget = false + +function setCompositionTarget(target: Node | null): void { + compositionTarget = target + compositionActiveWithoutTarget = false +} + +function installDocumentCompositionTracking(): void { + if (typeof document === 'undefined') { + return + } + document.addEventListener( + 'compositionstart', + (event) => setCompositionTarget(event.target as Node | null), + true + ) + document.addEventListener('compositionend', () => setCompositionTarget(null), true) + document.addEventListener('focusout', () => setCompositionTarget(null), true) +} + +installDocumentCompositionTracking() + +/** True while any element in the document is mid-composition. */ +export function isDocumentImeCompositionActive(): boolean { + if (compositionActiveWithoutTarget) { + return true + } + if (!compositionTarget) { + return false + } + if (!compositionTarget.isConnected) { + compositionTarget = null + return false + } + return true +} + +export function _setDocumentImeCompositionActiveForTests(active: boolean): void { + compositionTarget = null + compositionActiveWithoutTarget = active +} + /** - * Why: CJK IMEs (Japanese/Chinese/Korean) fire a keydown for the Enter that - * only confirms a conversion candidate. Rename/title inputs that commit on - * `Enter` must ignore that keydown, otherwise they submit mid-composition with a - * half-converted value. `isComposing` covers most browsers; `keyCode === 229` is - * a defensive fallback for IMEs that don't set `isComposing` on keydown. + * True when an IME owns this keystroke, so the app must not act on it. + * + * `keyCode === 229` and `key === 'Process'` are the Windows and Android markers for + * a key the IME consumed. 229 is also what catches Safari's extra keydown *after* + * compositionend, where the app-wide flag cannot help because compositionend has + * already cleared it — the numeric marker is the only bit still set. + * + * The flag covers the opposite gap: keys that arrive *during* a live composition + * carrying neither marker, which is what the per-event bits miss. + * + * Call this above the key dispatch, not inside each key branch, so every action + * (send, history navigation, completion, cancel) is suppressed together. + * + * The 229 / 'Process' markers are unconditional here — there is deliberately no + * opt-out argument. The only surface that needs a marked key to pass through is + * the terminal, so it can reach xterm's CompositionHelper, and that decision + * lives in xterm-bypass-policy.ts instead. Every caller of this predicate is + * ordinary UI, so an opt-out would only ever be dead API. */ -export function isImeCompositionKeyDown(event: ReactKeyboardEvent): boolean { - const nativeEvent = event.nativeEvent - return nativeEvent.isComposing || nativeEvent.keyCode === 229 +export function isImeCompositionKeyDown(event: AnyKeyboardEvent): boolean { + const nativeEvent = toNativeKeyboardEvent(event) + if (!nativeEvent) { + return isDocumentImeCompositionActive() + } + return ( + isDocumentImeCompositionActive() || + nativeEvent.isComposing === true || + nativeEvent.keyCode === 229 || + nativeEvent.key === 'Process' + ) } diff --git a/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts b/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts new file mode 100644 index 00000000000..35051982fad --- /dev/null +++ b/src/renderer/src/lib/ime-composition-trace.test-fixtures.ts @@ -0,0 +1,297 @@ +// Recorded-trace harness for IME tests. See the IME rules in AGENTS.md. +// +// Synthetic keystrokes cannot produce a real composition: KeyboardEvents dispatched +// through CDP bypass the OS input-method layer entirely. The only faithful way to +// test IME behavior is to replay an event sequence captured from a real engine, +// stamping the observed target state before each event so the code under test sees +// byte-for-byte what the browser produced. + +export type ImeTracePlatform = 'darwin' | 'linux' | 'win32' + +export type ImeTraceEnvironment = { + platform: ImeTracePlatform + browser: 'chromium' | 'gecko' | 'webkit' + /** Engine name for humans only. No production code may branch on it. */ + engine: string +} + +/** The input element's observed state at the instant an event fired. */ +export type ImeTraceTargetState = { + value: string + selectionStart: number + selectionEnd: number +} + +type ImeTraceEventBase = { state: ImeTraceTargetState } + +export type ImeTraceKeyEvent = ImeTraceEventBase & { + type: 'keydown' | 'keypress' | 'keyup' + key: string + code: string + keyCode: number + isComposing: boolean + altKey?: boolean + ctrlKey?: boolean + metaKey?: boolean + shiftKey?: boolean +} + +export type ImeTraceCompositionEvent = ImeTraceEventBase & { + type: 'compositionstart' | 'compositionupdate' | 'compositionend' + data: string +} + +export type ImeTraceInputEvent = ImeTraceEventBase & { + type: 'beforeinput' | 'input' + inputType: string + data: string | null + /** Safari reports `undefined` rather than `false`; the distinction is load-bearing. */ + isComposing: boolean | undefined +} + +export type ImeTraceEvent = ImeTraceCompositionEvent | ImeTraceInputEvent | ImeTraceKeyEvent + +/** + * `recorded` traces were captured from a real engine and are authoritative. + * `derived` traces were reconstructed from a documented event ordering; they pin + * current behavior but cannot prove the ordering is what the engine really emits. + * A fix justified only by a `derived` trace is unverified — say so in the PR. + */ +export type ImeTraceProvenance = 'derived' | 'recorded' + +export type ImeCompositionTrace = { + name: string + provenance: ImeTraceProvenance + /** What the human did, where the trace came from, and the issue it belongs to. */ + origin: string + env: ImeTraceEnvironment + initial: ImeTraceTargetState + events: ImeTraceEvent[] + final: ImeTraceTargetState + /** Text the surface under test must ultimately commit. Empty means "commit nothing". */ + committed: string +} + +export const EMPTY_IME_TRACE_STATE: ImeTraceTargetState = { + value: '', + selectionStart: 0, + selectionEnd: 0 +} + +function applyLegacyKeyCode(event: KeyboardEvent, keyCode: number): void { + if (event.keyCode === keyCode) { + return + } + // happy-dom ignores the legacy keyCode init member. + Object.defineProperty(event, 'keyCode', { configurable: true, value: keyCode }) + Object.defineProperty(event, 'which', { configurable: true, value: keyCode }) +} + +function applyIsComposing(event: Event, isComposing: boolean | undefined): void { + if ( + isComposing === undefined || + (event as { isComposing?: boolean }).isComposing === isComposing + ) { + return + } + // Testing libraries cannot set isComposing any other way. + Object.defineProperty(event, 'isComposing', { configurable: true, value: isComposing }) +} + +export function createImeTraceKeyboardEvent(event: ImeTraceKeyEvent): KeyboardEvent { + const created = new KeyboardEvent(event.type, { + altKey: event.altKey ?? false, + bubbles: true, + cancelable: true, + code: event.code, + ctrlKey: event.ctrlKey ?? false, + isComposing: event.isComposing, + key: event.key, + keyCode: event.keyCode, + metaKey: event.metaKey ?? false, + shiftKey: event.shiftKey ?? false + }) + applyLegacyKeyCode(created, event.keyCode) + applyIsComposing(created, event.isComposing) + return created +} + +export function createImeTraceCompositionEvent(event: ImeTraceCompositionEvent): CompositionEvent { + return new CompositionEvent(event.type, { bubbles: true, cancelable: true, data: event.data }) +} + +export function createImeTraceInputEvent(event: ImeTraceInputEvent): InputEvent { + const created = new InputEvent(event.type, { + bubbles: true, + cancelable: event.type === 'beforeinput', + composed: true, + data: event.data, + inputType: event.inputType, + isComposing: event.isComposing ?? false + }) + applyIsComposing(created, event.isComposing) + return created +} + +export function createImeTraceEvent(event: ImeTraceEvent): Event { + switch (event.type) { + case 'beforeinput': + case 'input': + return createImeTraceInputEvent(event) + case 'keydown': + case 'keypress': + case 'keyup': + return createImeTraceKeyboardEvent(event) + case 'compositionstart': + case 'compositionupdate': + case 'compositionend': + return createImeTraceCompositionEvent(event) + } +} + +export type ImeTraceReplayViolation = { + eventType: string + reason: string +} + +export type ImeTraceReplay = { + /** Invariant breaches observed during replay. A correct implementation leaves this empty. */ + violations: ImeTraceReplayViolation[] + /** Every event dispatched, in order, for ordering assertions. */ + dispatched: Event[] +} + +export type ImeTraceReplayOptions = { + /** Runs after each event so tests can observe intermediate state. */ + onEvent?: (event: Event, index: number) => void + /** Yields between events to preserve async ordering. Defaults to a microtask. */ + yieldBetweenEvents?: () => Promise +} + +function readTargetState(target: HTMLTextAreaElement | HTMLInputElement): ImeTraceTargetState { + return { + selectionEnd: target.selectionEnd ?? 0, + selectionStart: target.selectionStart ?? 0, + value: target.value + } +} + +function stampTargetState( + target: HTMLTextAreaElement | HTMLInputElement, + state: ImeTraceTargetState +): void { + target.value = state.value + target.setSelectionRange(state.selectionStart, state.selectionEnd) +} + +/** + * Replays a recorded trace against a real input element. + * + * The recorded state is stamped in *before* each event fires, so the code under test + * sees the browser's actual buffer rather than a simulation of it. + */ +export async function replayImeCompositionTrace( + target: HTMLTextAreaElement | HTMLInputElement, + trace: ImeCompositionTrace, + options: ImeTraceReplayOptions = {} +): Promise { + const violations: ImeTraceReplayViolation[] = [] + const dispatched: Event[] = [] + const yieldBetweenEvents = options.yieldBetweenEvents ?? (() => Promise.resolve()) + + stampTargetState(target, trace.initial) + + for (const [index, event] of trace.events.entries()) { + stampTargetState(target, event.state) + const stamped = readTargetState(target) + const created = createImeTraceEvent(event) + + target.dispatchEvent(created) + dispatched.push(created) + + const after = readTargetState(target) + if (event.type === 'compositionstart' && after.value !== stamped.value) { + // Clearing the textarea here makes the browser skip compositionend entirely. + violations.push({ + eventType: event.type, + reason: `wrote ${JSON.stringify(after.value)} to the target during compositionstart` + }) + } + + options.onEvent?.(created, index) + await yieldBetweenEvents() + } + + return { dispatched, violations } +} + +export function createImeTraceTextarea(document: Document): HTMLTextAreaElement { + const textarea = document.createElement('textarea') + document.body.appendChild(textarea) + textarea.focus() + return textarea +} + +/** One committed chunk, positioned by range rather than by diffing the buffer. */ +export type ImeCommit = { + text: string + /** Preedit characters immediately before the caret that this commit replaces. */ + replacePrevCharCnt?: number +} + +/** + * Splices commits into the trace's initial text at the caret and returns the resulting + * state. Commits replace rather than only append: Hangul input commits each jamo and + * then re-commits the composed syllable over it, so most events carry + * `replacePrevCharCnt: 1`, and an implementation that appended those instead would + * produce the right characters in the wrong order. + */ +export function interpretImeCommits( + initial: ImeTraceTargetState, + commits: readonly ImeCommit[] +): ImeTraceTargetState { + let value = initial.value + let caret = initial.selectionStart + + for (const commit of commits) { + const replaceFrom = Math.max(0, caret - (commit.replacePrevCharCnt ?? 0)) + value = value.slice(0, replaceFrom) + commit.text + value.slice(caret) + caret = replaceFrom + commit.text.length + } + + return { selectionEnd: caret, selectionStart: caret, value } +} + +/** + * Reads the commits a correct surface must derive from this trace, using only event + * `data` — never a diff of two buffer observations, per the rule in AGENTS.md. + * + * Two shapes deliver a commit. Most engines follow `compositionend` with an + * `insertText` carrying the committed text. ibus-hangul often does not: it leaves the + * text in the buffer from the last `insertCompositionText` and `compositionend` is the + * only carrier. Taking both without this de-duplication would double every commit. + */ +export function extractImeCommitsFromTrace(trace: ImeCompositionTrace): ImeCommit[] { + const commits: ImeCommit[] = [] + + for (const [index, event] of trace.events.entries()) { + if (event.type === 'input' && event.inputType === 'insertText' && event.data !== null) { + commits.push({ text: event.data }) + continue + } + if (event.type !== 'compositionend' || event.data === '') { + continue + } + // Only the window before the next composition or plain keystroke counts: a later + // insertText from ordinary typing must not cancel a retained commit. + const followUp = trace.events + .slice(index + 1) + .find((later) => later.type === 'input' || later.type === 'keydown') + const deliveredByInsertText = followUp?.type === 'input' && followUp.inputType === 'insertText' + if (!deliveredByInsertText) { + commits.push({ text: event.data }) + } + } + + return commits +} diff --git a/src/renderer/src/lib/ime-composition-trace.test.ts b/src/renderer/src/lib/ime-composition-trace.test.ts new file mode 100644 index 00000000000..a1bb2318c90 --- /dev/null +++ b/src/renderer/src/lib/ime-composition-trace.test.ts @@ -0,0 +1,194 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it } from 'vitest' +import { + createImeTraceTextarea, + extractImeCommitsFromTrace, + interpretImeCommits, + replayImeCompositionTrace +} from './ime-composition-trace.test-fixtures' +import type { ImeTraceKeyEvent } from './ime-composition-trace.test-fixtures' +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' +import { + IBUS_HANGUL_MIXED_LATIN_TRACE, + IBUS_HANGUL_RETAINED_COMMIT_TRACE, + IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE, + IME_COMPOSITION_TRACES +} from './ime-recorded-composition-traces.test-fixtures' + +afterEach(() => { + document.body.replaceChildren() +}) + +describe('recorded composition traces', () => { + it.each(IME_COMPOSITION_TRACES.map((trace) => [trace.name, trace] as const))( + '%s replays to its recorded final state', + async (_name, trace) => { + const textarea = createImeTraceTextarea(document) + + const replay = await replayImeCompositionTrace(textarea, trace) + + expect(replay.violations).toEqual([]) + expect(replay.dispatched).toHaveLength(trace.events.length) + // Not asserted here: the textarea's final contents. The harness stamps each + // event's recorded state in before dispatching it, so after the last event the + // textarea necessarily holds `trace.final` no matter what any listener did. + // The real check is the independent interpretation below. + } + ) + + it.each(IME_COMPOSITION_TRACES.map((trace) => [trace.name, trace] as const))( + '%s reaches its final text from event data alone', + (_name, trace) => { + // Derived from the event stream, never from the stamped buffer, so this fails + // when a trace's declared data does not actually add up to what it claims. + const commits = extractImeCommitsFromTrace(trace) + + expect(commits.map((commit) => commit.text).join('')).toBe(trace.committed) + expect(interpretImeCommits(trace.initial, commits)).toEqual(trace.final) + } + ) + + it('reports the wrong text when commits are appended instead of replaced', () => { + // Guards the interpreter itself: Hangul commits carry replacePrevCharCnt: 1 on + // most events, and an appending implementation would score as correct without this. + const initial = { selectionEnd: 0, selectionStart: 0, value: '' } + const replacing = [{ text: 'ㅇ' }, { replacePrevCharCnt: 1, text: '아' }] + + expect(interpretImeCommits(initial, replacing).value).toBe('아') + expect(interpretImeCommits(initial, [{ text: 'ㅇ' }, { text: '아' }]).value).toBe('ㅇ아') + }) + + it('classifies the recorded Enter that ibus-hangul consumed to commit', () => { + // Ground truth for what a real engine actually sends, which the derived traces were + // only guessing at: ibus-hangul sets all three markers on the committing Enter, so + // any one of them would catch it here. The union still earns its keep — the Windows + // and Safari cases in ime-composition-keyboard-event.test.ts carry only some of them. + const committingEnter = IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.events.find( + (event) => event.type === 'keydown' && event.code === 'Enter' + ) + + expect(committingEnter).toMatchObject({ isComposing: true, key: 'Process', keyCode: 229 }) + expect(isImeCompositionKeyDown(committingEnter as ImeTraceKeyEvent)).toBe(true) + // The paired negative: an otherwise identical Enter with no IME marker must submit. + expect(isImeCompositionKeyDown({ isComposing: false, key: 'Enter', keyCode: 13 })).toBe(false) + }) + + it('derives no commit from the plain ASCII typed between two compositions', () => { + // 'abc' goes straight to the PTY in the recorded trace and never becomes a commit. + const commits = extractImeCommitsFromTrace(IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE) + + expect(commits.map((commit) => commit.text)).toEqual(['한', '글']) + }) + + it('survives the deleteContentBackward that ibus-hangul emits before committing', () => { + // A buffer-diffing implementation loses the character here: the engine tears the + // preedit down first, so the buffer is shorter immediately before the commit. + const events = IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.events + const teardown = events.findIndex( + (event) => event.type === 'input' && event.inputType === 'deleteContentBackward' + ) + + expect(teardown).toBeGreaterThan(-1) + expect(events[teardown + 1]?.type).toBe('compositionend') + expect( + interpretImeCommits( + IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.initial, + extractImeCommitsFromTrace(IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE) + ) + ).toEqual(IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE.final) + }) + + it('keeps a retained commit that ordinary typing follows', () => { + // The de-dup must not let a later insertText from plain typing cancel a + // compositionend that was the only carrier of its commit. + const retained = IME_COMPOSITION_TRACES.find((trace) => trace.name.includes('retained commit')) + + expect(extractImeCommitsFromTrace(retained!).map((c) => c.text)).toEqual(['테', '스']) + }) + + it.each(IME_COMPOSITION_TRACES.map((trace) => [trace.name, trace] as const))( + '%s declares a non-empty origin and a provenance', + (_name, trace) => { + expect(trace.origin.length).toBeGreaterThan(0) + expect(['derived', 'recorded']).toContain(trace.provenance) + } + ) + + it('marks every event with the buffer state observed at that instant', () => { + for (const trace of IME_COMPOSITION_TRACES) { + for (const event of trace.events) { + expect(event.state.selectionStart).toBeLessThanOrEqual(event.state.value.length) + expect(event.state.selectionEnd).toBeLessThanOrEqual(event.state.value.length) + } + } + }) +}) + +describe('replayImeCompositionTrace', () => { + it('stamps the recorded buffer before each event rather than simulating it', async () => { + const textarea = createImeTraceTextarea(document) + const seen: string[] = [] + textarea.addEventListener('compositionupdate', () => seen.push(textarea.value)) + + await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE) + + // The engine reports the pre-edit buffer on compositionupdate, not the composed text. + expect(seen).toEqual(['', '테']) + }) + + it('reports a listener that writes to the buffer during compositionstart', async () => { + const textarea = createImeTraceTextarea(document) + textarea.addEventListener('compositionstart', () => { + // Clearing here makes real browsers skip compositionend entirely. + textarea.value = '' + }) + + const replay = await replayImeCompositionTrace(textarea, IBUS_HANGUL_MIXED_LATIN_TRACE) + + expect(replay.violations).toEqual([ + { eventType: 'compositionstart', reason: 'wrote "" to the target during compositionstart' } + ]) + }) + + it('yields between events so async handlers observe the recorded ordering', async () => { + const textarea = createImeTraceTextarea(document) + const order: string[] = [] + + await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE, { + onEvent: (event) => order.push(event.type), + yieldBetweenEvents: () => Promise.resolve() + }) + + expect(order.slice(0, 4)).toEqual([ + 'compositionstart', + 'keydown', + 'compositionupdate', + 'beforeinput' + ]) + }) + + it('preserves the legacy keyCode engines report for IME-consumed keys', async () => { + const textarea = createImeTraceTextarea(document) + const processKeyCodes: number[] = [] + textarea.addEventListener('keydown', (event) => { + if (event.key === 'Process') { + processKeyCodes.push(event.keyCode) + } + }) + + await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE) + + expect(processKeyCodes).toEqual([229, 229]) + }) + + it('preserves isComposing on the keystrokes the IME owns', async () => { + const textarea = createImeTraceTextarea(document) + const composing: boolean[] = [] + textarea.addEventListener('keydown', (event) => composing.push(event.isComposing)) + + await replayImeCompositionTrace(textarea, IBUS_HANGUL_RETAINED_COMMIT_TRACE) + + // Process keys are composing; the interleaved 'a' and the trailing Enter are not. + expect(composing).toEqual([true, false, true, false]) + }) +}) diff --git a/src/renderer/src/lib/ime-recorded-composition-traces.test-fixtures.ts b/src/renderer/src/lib/ime-recorded-composition-traces.test-fixtures.ts new file mode 100644 index 00000000000..a5e48ca7863 --- /dev/null +++ b/src/renderer/src/lib/ime-recorded-composition-traces.test-fixtures.ts @@ -0,0 +1,218 @@ +// Composition traces used across the IME suites. See the IME rules in AGENTS.md. +// +// Adding a trace: prefer capturing one from a real engine and mark it `recorded`. +// A `derived` trace pins current behavior but proves nothing about the engine. + +import type { + ImeCompositionTrace, + ImeTraceEvent, + ImeTraceTargetState +} from './ime-composition-trace.test-fixtures' +import { IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE } from './ime-recorded-ibus-hangul-trace.test-fixtures' + +export { IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE } + +/** Caret collapsed at the end of `value`, which is where every trace below sits. */ +function caretAtEnd(value: string): ImeTraceTargetState { + return { selectionEnd: value.length, selectionStart: value.length, value } +} + +function compositionStart(value: string): ImeTraceEvent { + return { data: '', state: caretAtEnd(value), type: 'compositionstart' } +} + +function compositionUpdate(value: string, data: string): ImeTraceEvent { + return { data, state: caretAtEnd(value), type: 'compositionupdate' } +} + +function compositionEnd(value: string, data: string): ImeTraceEvent { + return { data, state: caretAtEnd(value), type: 'compositionend' } +} + +function processKeydown(value: string, code: string): ImeTraceEvent { + return { + code, + isComposing: true, + key: 'Process', + keyCode: 229, + state: caretAtEnd(value), + type: 'keydown' + } +} + +function plainKeydown(value: string, key: string, code: string, keyCode: number): ImeTraceEvent { + return { code, isComposing: false, key, keyCode, state: caretAtEnd(value), type: 'keydown' } +} + +/** `beforeinput` carries the pre-edit buffer; `input` carries the post-edit buffer. */ +function editPair(before: string, after: string, inputType: string, data: string): ImeTraceEvent[] { + const isComposing = inputType === 'insertCompositionText' + return [ + { data, inputType, isComposing, state: caretAtEnd(before), type: 'beforeinput' }, + { data, inputType, isComposing, state: caretAtEnd(after), type: 'input' } + ] +} + +/** + * Hangul syllables committed without a trailing `insertText` — the composed text is + * retained in the buffer from the last `insertCompositionText`. A surface that waits + * for `insertText` before committing drops both syllables. + */ +export const IBUS_HANGUL_RETAINED_COMMIT_TRACE: ImeCompositionTrace = { + committed: '테스', + env: { browser: 'chromium', engine: 'ibus-hangul', platform: 'linux' }, + events: [ + compositionStart(''), + processKeydown('', 'KeyG'), + compositionUpdate('', '테'), + ...editPair('', '테', 'insertCompositionText', '테'), + compositionEnd('테', '테'), + plainKeydown('테', 'a', 'KeyA', 65), + processKeydown('테', 'KeyR'), + compositionStart('테'), + compositionUpdate('테', '스'), + ...editPair('테', '테스', 'insertCompositionText', '스'), + compositionEnd('테스', '스'), + plainKeydown('테스', 'Enter', 'Enter', 13) + ], + final: caretAtEnd('테스'), + initial: caretAtEnd(''), + name: 'Linux - Chromium - ibus-hangul - retained commit', + origin: + 'Transcribed from the synthetic sequence terminal-ime-e2e replays in CI ' + + '(tests/e2e/terminal-ime-observed-event-sequences.ts), which hand-dispatches these ' + + 'events rather than capturing them. Selection offsets are reconstructed as a collapsed ' + + 'caret at end-of-buffer. To promote this to `recorded`, capture it through ' + + 'tests/e2e/terminal-ime-boundary-probe.ts, which reads true offsets off a live ' + + 'ibus-hangul session.', + provenance: 'derived' +} + +/** + * Hangul, then Latin typed directly, then Hangul again. Pins that a surface which + * defers on composition does not also defer the interleaved plain keystrokes. + */ +export const IBUS_HANGUL_MIXED_LATIN_TRACE: ImeCompositionTrace = { + committed: '한abc글', + env: { browser: 'chromium', engine: 'ibus-hangul', platform: 'linux' }, + events: [ + compositionStart(''), + processKeydown('', 'KeyG'), + compositionUpdate('', '한'), + ...editPair('', '한', 'insertCompositionText', '한'), + compositionUpdate('한', ''), + ...editPair('한', '', 'deleteContentBackward', ''), + compositionEnd('', ''), + ...editPair('', '한', 'insertText', '한'), + plainKeydown('한', 'a', 'KeyA', 65), + ...editPair('한', '한a', 'insertText', 'a'), + plainKeydown('한a', 'b', 'KeyB', 66), + ...editPair('한a', '한ab', 'insertText', 'b'), + plainKeydown('한ab', 'c', 'KeyC', 67), + ...editPair('한ab', '한abc', 'insertText', 'c'), + compositionStart('한abc'), + processKeydown('한abc', 'KeyG'), + compositionUpdate('한abc', '글'), + ...editPair('한abc', '한abc글', 'insertCompositionText', '글'), + compositionUpdate('한abc글', ''), + ...editPair('한abc글', '한abc', 'deleteContentBackward', ''), + compositionEnd('한abc', ''), + ...editPair('한abc', '한abc글', 'insertText', '글'), + plainKeydown('한abc글', 'Enter', 'Enter', 13) + ], + final: caretAtEnd('한abc글'), + initial: caretAtEnd(''), + name: 'Linux - Chromium - ibus-hangul - mixed with Latin', + origin: + 'Transcribed from the synthetic sequence terminal-ime-e2e replays in CI ' + + '(tests/e2e/terminal-ime-observed-event-sequences.ts), which hand-dispatches these ' + + 'events rather than capturing them. Note the commit arrives as a deleteContentBackward ' + + 'that empties the preedit followed by insertText — a length-only diff sees the delete ' + + 'and sends a backspace. Promote via tests/e2e/terminal-ime-boundary-probe.ts.', + provenance: 'derived' +} + +/** + * Vietnamese Telex: `a` `a` composes `â`, then Backspace decomposes it back to `a`. + * Backspace is IME-owned here — forwarding it to the PTY deletes real shell input. + */ +export const TELEX_BACKSPACE_MID_COMPOSITION_TRACE: ImeCompositionTrace = { + committed: 'a', + env: { browser: 'chromium', engine: 'Telex', platform: 'darwin' }, + events: [ + compositionStart(''), + processKeydown('', 'KeyA'), + compositionUpdate('', 'a'), + ...editPair('', 'a', 'insertCompositionText', 'a'), + processKeydown('a', 'KeyA'), + compositionUpdate('a', 'â'), + ...editPair('a', 'â', 'insertCompositionText', 'â'), + { + code: 'Backspace', + isComposing: true, + key: 'Backspace', + keyCode: 8, + state: caretAtEnd('â'), + type: 'keydown' + }, + compositionUpdate('â', 'a'), + ...editPair('â', 'a', 'insertCompositionText', 'a'), + compositionEnd('a', 'a') + ], + final: caretAtEnd('a'), + initial: caretAtEnd(''), + name: 'macOS - Chromium - Telex - Backspace decomposes mid-composition', + origin: + 'Derived from the symptom in orca#6494 / #6698 / #6905: Backspace during a Telex ' + + 'composition reaches the PTY instead of the IME. Not hardware-recorded — the ordering ' + + 'follows the documented compositionupdate-per-keystroke contract.', + provenance: 'derived' +} + +/** + * Arrow keys select a candidate; they must not reach the shell's readline. + */ +export const CANDIDATE_ARROW_NAVIGATION_TRACE: ImeCompositionTrace = { + committed: '中', + env: { browser: 'chromium', engine: 'Pinyin', platform: 'darwin' }, + events: [ + compositionStart(''), + processKeydown('', 'KeyZ'), + compositionUpdate('', 'zhong'), + ...editPair('', 'zhong', 'insertCompositionText', 'zhong'), + { + code: 'ArrowDown', + isComposing: true, + key: 'ArrowDown', + keyCode: 40, + state: caretAtEnd('zhong'), + type: 'keydown' + }, + { + code: 'ArrowUp', + isComposing: true, + key: 'ArrowUp', + keyCode: 38, + state: caretAtEnd('zhong'), + type: 'keydown' + }, + compositionUpdate('zhong', '中'), + ...editPair('zhong', '中', 'insertCompositionText', '中'), + compositionEnd('中', '中') + ], + final: caretAtEnd('中'), + initial: caretAtEnd(''), + name: 'macOS - Chromium - Pinyin - arrow candidate navigation', + origin: + 'Derived from orca#9803: arrow keys during composition move the shell cursor. ' + + 'Not hardware-recorded.', + provenance: 'derived' +} + +export const IME_COMPOSITION_TRACES: readonly ImeCompositionTrace[] = [ + IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE, + IBUS_HANGUL_RETAINED_COMMIT_TRACE, + IBUS_HANGUL_MIXED_LATIN_TRACE, + TELEX_BACKSPACE_MID_COMPOSITION_TRACE, + CANDIDATE_ARROW_NAVIGATION_TRACE +] diff --git a/src/renderer/src/lib/ime-recorded-ibus-hangul-trace.json b/src/renderer/src/lib/ime-recorded-ibus-hangul-trace.json new file mode 100644 index 00000000000..62e68b367e1 --- /dev/null +++ b/src/renderer/src/lib/ime-recorded-ibus-hangul-trace.json @@ -0,0 +1,568 @@ +{ + "committed": "한글", + "env": { + "browser": "chromium", + "engine": "ibus-hangul", + "platform": "linux" + }, + "events": [ + { + "code": "KeyG", + "isComposing": false, + "key": "Process", + "keyCode": 229, + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "keydown" + }, + { + "data": "", + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "compositionstart" + }, + { + "data": "ㅎ", + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "compositionupdate" + }, + { + "data": "ㅎ", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "beforeinput" + }, + { + "data": "ㅎ", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "ㅎ" + }, + "type": "input" + }, + { + "code": "KeyG", + "isComposing": true, + "key": "g", + "keyCode": 71, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "ㅎ" + }, + "type": "keyup" + }, + { + "code": "KeyK", + "isComposing": true, + "key": "k", + "keyCode": 75, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "ㅎ" + }, + "type": "keyup" + }, + { + "data": "하", + "state": { + "selectionEnd": 1, + "selectionStart": 0, + "value": "ㅎ" + }, + "type": "compositionupdate" + }, + { + "data": "하", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 0, + "value": "ㅎ" + }, + "type": "beforeinput" + }, + { + "data": "하", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "하" + }, + "type": "input" + }, + { + "code": "KeyS", + "isComposing": true, + "key": "s", + "keyCode": 83, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "하" + }, + "type": "keyup" + }, + { + "data": "한", + "state": { + "selectionEnd": 1, + "selectionStart": 0, + "value": "하" + }, + "type": "compositionupdate" + }, + { + "data": "한", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 0, + "value": "하" + }, + "type": "beforeinput" + }, + { + "data": "한", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "input" + }, + { + "code": "AltRight", + "isComposing": true, + "key": "Process", + "keyCode": 229, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keydown" + }, + { + "data": "", + "state": { + "selectionEnd": 1, + "selectionStart": 0, + "value": "한" + }, + "type": "compositionupdate" + }, + { + "data": "", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 0, + "value": "한" + }, + "type": "beforeinput" + }, + { + "data": null, + "inputType": "deleteContentBackward", + "isComposing": false, + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "input" + }, + { + "data": "", + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "compositionend" + }, + { + "data": "한", + "inputType": "insertText", + "isComposing": false, + "state": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "type": "beforeinput" + }, + { + "data": "한", + "inputType": "insertText", + "isComposing": false, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "input" + }, + { + "code": "AltRight", + "isComposing": false, + "key": "HangulMode", + "keyCode": 21, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keyup" + }, + { + "code": "KeyA", + "isComposing": false, + "key": "a", + "keyCode": 65, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keydown" + }, + { + "code": "KeyA", + "isComposing": false, + "key": "a", + "keyCode": 65, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keyup" + }, + { + "code": "KeyB", + "isComposing": false, + "key": "b", + "keyCode": 66, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keydown" + }, + { + "code": "KeyB", + "isComposing": false, + "key": "b", + "keyCode": 66, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keyup" + }, + { + "code": "KeyC", + "isComposing": false, + "key": "c", + "keyCode": 67, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keydown" + }, + { + "code": "KeyC", + "isComposing": false, + "key": "c", + "keyCode": 67, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keyup" + }, + { + "code": "AltRight", + "isComposing": false, + "key": "HangulMode", + "keyCode": 21, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keyup" + }, + { + "code": "KeyR", + "isComposing": false, + "key": "r", + "keyCode": 82, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "keyup" + }, + { + "data": "", + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "compositionstart" + }, + { + "data": "ㄱ", + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "compositionupdate" + }, + { + "data": "ㄱ", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "beforeinput" + }, + { + "data": "ㄱ", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한ㄱ" + }, + "type": "input" + }, + { + "code": "KeyM", + "isComposing": true, + "key": "Process", + "keyCode": 229, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한ㄱ" + }, + "type": "keydown" + }, + { + "data": "그", + "state": { + "selectionEnd": 2, + "selectionStart": 1, + "value": "한ㄱ" + }, + "type": "compositionupdate" + }, + { + "data": "그", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 2, + "selectionStart": 1, + "value": "한ㄱ" + }, + "type": "beforeinput" + }, + { + "data": "그", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한그" + }, + "type": "input" + }, + { + "code": "KeyM", + "isComposing": true, + "key": "m", + "keyCode": 77, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한그" + }, + "type": "keyup" + }, + { + "code": "KeyF", + "isComposing": true, + "key": "f", + "keyCode": 70, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한그" + }, + "type": "keyup" + }, + { + "data": "글", + "state": { + "selectionEnd": 2, + "selectionStart": 1, + "value": "한그" + }, + "type": "compositionupdate" + }, + { + "data": "글", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 2, + "selectionStart": 1, + "value": "한그" + }, + "type": "beforeinput" + }, + { + "data": "글", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한글" + }, + "type": "input" + }, + { + "code": "Enter", + "isComposing": true, + "key": "Process", + "keyCode": 229, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한글" + }, + "type": "keydown" + }, + { + "data": "", + "state": { + "selectionEnd": 2, + "selectionStart": 1, + "value": "한글" + }, + "type": "compositionupdate" + }, + { + "data": "", + "inputType": "insertCompositionText", + "isComposing": true, + "state": { + "selectionEnd": 2, + "selectionStart": 1, + "value": "한글" + }, + "type": "beforeinput" + }, + { + "data": null, + "inputType": "deleteContentBackward", + "isComposing": false, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "input" + }, + { + "data": "", + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "compositionend" + }, + { + "data": "글", + "inputType": "insertText", + "isComposing": false, + "state": { + "selectionEnd": 1, + "selectionStart": 1, + "value": "한" + }, + "type": "beforeinput" + }, + { + "data": "글", + "inputType": "insertText", + "isComposing": false, + "state": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한글" + }, + "type": "input" + } + ], + "final": { + "selectionEnd": 2, + "selectionStart": 2, + "value": "한글" + }, + "initial": { + "selectionEnd": 0, + "selectionStart": 0, + "value": "" + }, + "name": "ibus-hangul terminal jamo assembly and commit", + "origin": "Captured from a live ibus-hangul engine by tests/e2e/terminal-ime-boundary-probe.ts during the terminal-ime-e2e workflow (run 30691057123, ubuntu-22.04). xdotool types at the X11 layer so the engine really composes; the selection offsets are the engine's own, not a reconstruction. First repetition of the 한abc글 sequence, sliced at the last event before xterm clears the helper textarea. PTY oracle: ['한','a','b','c','글','\\r'].", + "pty": ["한", "a", "b", "c", "글", "\r"], + "provenance": "recorded" +} diff --git a/src/renderer/src/lib/ime-recorded-ibus-hangul-trace.test-fixtures.ts b/src/renderer/src/lib/ime-recorded-ibus-hangul-trace.test-fixtures.ts new file mode 100644 index 00000000000..1216b4148d8 --- /dev/null +++ b/src/renderer/src/lib/ime-recorded-ibus-hangul-trace.test-fixtures.ts @@ -0,0 +1,83 @@ +// Loads the raw ibus-hangul recording. The recording itself lives in the adjacent +// .json because it is machine output, not source: re-capture it, never hand-edit it. +// +// Capture path: tests/e2e/terminal-ime-boundary-probe.ts reads the live +// .xterm-helper-textarea while xdotool types at the X11 layer, so a real engine +// composes and the selection offsets are its own rather than a reconstruction. +// See the `origin` field in the JSON for the exact workflow run. + +import type { + ImeCompositionTrace, + ImeTraceEvent, + ImeTraceTargetState +} from './ime-composition-trace.test-fixtures' +import recording from './ime-recorded-ibus-hangul-trace.json' + +const COMPOSITION_TYPES = new Set(['compositionend', 'compositionstart', 'compositionupdate']) +const KEY_TYPES = new Set(['keydown', 'keypress', 'keyup']) +const INPUT_TYPES = new Set(['beforeinput', 'input']) + +/** + * Narrows the JSON to the trace type. A blanket structural cast would hide the drift + * this is here to catch: a re-captured recording that gained or lost a field would + * type-check and then fail somewhere unrelated. + */ +function toTraceEvent(raw: (typeof recording)['events'][number], index: number): ImeTraceEvent { + const state = raw.state as ImeTraceTargetState + if (COMPOSITION_TYPES.has(raw.type)) { + return { + data: raw.data ?? '', + state, + type: raw.type as 'compositionend' | 'compositionstart' | 'compositionupdate' + } + } + if (KEY_TYPES.has(raw.type)) { + return { + code: raw.code ?? '', + isComposing: raw.isComposing === true, + key: raw.key ?? '', + keyCode: raw.keyCode ?? 0, + state, + type: raw.type as 'keydown' | 'keypress' | 'keyup' + } + } + if (INPUT_TYPES.has(raw.type)) { + return { + data: raw.data ?? null, + inputType: raw.inputType ?? '', + isComposing: raw.isComposing === true, + state, + type: raw.type as 'beforeinput' | 'input' + } + } + throw new Error(`Unrecognized recorded event type "${raw.type}" at index ${index}`) +} + +/** + * Real jamo assembly followed by a commit. Three things here are not reproducible by + * hand, which is the whole reason this recording exists: + * + * 1. The Enter that commits arrives as `key: 'Process'`, `keyCode: 229`, + * `isComposing: true`. The submit Enter that follows the slice is `keyCode: 13`. + * 2. ibus-hangul tears the preedit down with `deleteContentBackward` and only *then* + * re-inserts the committed text with `insertText`. Anything deriving committed text + * by diffing the buffer sees the delete and loses the character. + * 3. `compositionend` carries `data: ''`, so it is not the commit carrier here. + */ +export const IBUS_HANGUL_TERMINAL_JAMO_COMMIT_TRACE: ImeCompositionTrace = { + committed: recording.committed, + env: { + browser: 'chromium', + engine: recording.env.engine, + platform: 'linux' + }, + events: recording.events.map(toTraceEvent), + final: recording.final, + initial: recording.initial, + name: recording.name, + origin: recording.origin, + provenance: 'recorded' +} + +/** Bytes the terminal actually forwarded to the PTY during this recording. */ +export const IBUS_HANGUL_TERMINAL_PTY_ORACLE: readonly string[] = recording.pty diff --git a/src/renderer/src/lib/new-workspace-enter-guard.test.ts b/src/renderer/src/lib/new-workspace-enter-guard.test.ts index e18f6321d22..65e05c81bf0 100644 --- a/src/renderer/src/lib/new-workspace-enter-guard.test.ts +++ b/src/renderer/src/lib/new-workspace-enter-guard.test.ts @@ -4,11 +4,10 @@ import { shouldSuppressEnterSubmit } from './new-workspace-enter-guard' -function makeEvent(overrides: Partial<{ isComposing: boolean; shiftKey: boolean }>): { - isComposing: boolean - shiftKey: boolean -} { - return { isComposing: false, shiftKey: false, ...overrides } +function makeEvent( + overrides: Partial> +): Pick { + return { isComposing: false, key: 'Enter', keyCode: 13, shiftKey: false, ...overrides } } class FakeHTMLElement extends EventTarget { @@ -34,6 +33,11 @@ describe('shouldSuppressEnterSubmit', () => { expect(shouldSuppressEnterSubmit(makeEvent({ isComposing: true }), false)).toBe(true) }) + it('returns true for an IME-owned Enter carrying only the Windows 229 marker', () => { + // Bare isComposing missed this; the central predicate is what catches it. + expect(shouldSuppressEnterSubmit(makeEvent({ key: 'Process', keyCode: 229 }), false)).toBe(true) + }) + it('returns true for Shift+Enter inside a textarea', () => { expect(shouldSuppressEnterSubmit(makeEvent({ shiftKey: true }), true)).toBe(true) }) diff --git a/src/renderer/src/lib/new-workspace-enter-guard.ts b/src/renderer/src/lib/new-workspace-enter-guard.ts index 0359a4892a5..8df85106c49 100644 --- a/src/renderer/src/lib/new-workspace-enter-guard.ts +++ b/src/renderer/src/lib/new-workspace-enter-guard.ts @@ -1,3 +1,5 @@ +import { isImeCompositionKeyDown } from './ime-composition-keyboard-event' + /** * Returns true when an Enter keydown event should be suppressed for submit actions. * @@ -6,10 +8,12 @@ * 2. Shift+Enter inside a textarea — intended as a newline, not a submit. */ export function shouldSuppressEnterSubmit( - event: { isComposing: boolean; shiftKey: boolean }, + event: Pick, isTextarea: boolean ): boolean { - if (event.isComposing) { + // Delegates so these gates get the 229 / 'Process' markers and the live-composition + // flag too; bare isComposing misses an IME-owned Enter on Windows and Android. + if (isImeCompositionKeyDown(event)) { return true } if (isTextarea && event.shiftKey) {