diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..690f80ec20 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -417,6 +417,9 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (globalKeybindingsDisabled) { return false; } + if (waveEvent.isComposing) { + return false; + } const nativeEvent = (waveEvent as any).nativeEvent; if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) { return false; diff --git a/frontend/app/view/term/ijson.tsx b/frontend/app/view/term/ijson.tsx index 617a6e094d..472c4c0cfd 100644 --- a/frontend/app/view/term/ijson.tsx +++ b/frontend/app/view/term/ijson.tsx @@ -104,7 +104,7 @@ body { } .fixed-font { - normal 12px / normal "Hack", monospace; + font: normal 12px / normal "Hack", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK JP", "Noto Sans Mono CJK SC", "Noto Sans Mono CJK TC", "Noto Sans CJK KR", "Noto Sans CJK JP", "Noto Sans CJK SC", "Noto Sans CJK TC", "Apple SD Gothic Neo", "Hiragino Sans", "PingFang SC", "PingFang TC", "Microsoft YaHei", "Malgun Gothic", monospace; } `} diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..3af9f23950 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -697,6 +697,9 @@ export class TermViewModel implements ViewModel { } handleTerminalKeydown(event: KeyboardEvent): boolean { + if (event.isComposing || event.keyCode == 229) { + return true; + } const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (waveEvent.type != "keydown") { return true; diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 67eb5737c6..085c16d91e 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -29,6 +29,10 @@ import { TermWrap } from "./termwrap"; import "./xterm.css"; const dlog = debug("wave:term"); +const DefaultTermFontFamily = + "Hack, 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', " + + "'Noto Sans CJK KR', 'Noto Sans CJK JP', 'Noto Sans CJK SC', 'Noto Sans CJK TC', " + + "'Apple SD Gothic Neo', 'Hiragino Sans', 'PingFang SC', 'PingFang TC', 'Microsoft YaHei', 'Malgun Gothic', monospace"; interface TerminalViewProps { blockId: string; @@ -292,6 +296,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false; const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, "term:cursor"))); const termCursorBlink = globalStore.get(getOverrideConfigAtom(blockId, "term:cursorblink")) ?? false; + const termDisableWebGl = termSettings?.["term:disablewebgl"] ?? true; const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused); const termWrap = new TermWrap( tabModel.tabId, @@ -300,7 +305,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => { theme: termTheme, fontSize: termFontSize, - fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack", + fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? DefaultTermFontFamily, drawBoldTextInBrightColors: false, fontWeight: "normal", fontWeightBold: "bold", @@ -315,7 +320,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => }, { keydownHandler: model.handleTerminalKeydown.bind(model), - useWebGl: !termSettings?.["term:disablewebgl"], + useWebGl: !termDisableWebGl, sendDataHandler: model.sendDataToController.bind(model), nodeModel: model.nodeModel, } diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d10b600459..052cfe5afa 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -111,6 +111,12 @@ export class TermWrap { lastPasteData: string = ""; lastPasteTime: number = 0; + // IME composition ordering + compositionActive: boolean = false; + compositionRecentlyEndedUntil: number = 0; + pendingCompositionSuffix: { data: string; timeout: ReturnType } | null = null; + disposed: boolean = false; + // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; @@ -272,6 +278,12 @@ export class TermWrap { }) ); this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { + if (e.isComposing || e.keyCode == 229) { + return true; + } + if (this.shouldBypassWaveKeydownForComposition(e)) { + return true; + } if (!waveOptions.keydownHandler) { return true; } @@ -282,6 +294,7 @@ export class TermWrap { this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); + this.registerCompositionEventHandlers(); const dragoverHandler = (e: DragEvent) => { e.preventDefault(); @@ -442,6 +455,7 @@ export class TermWrap { } dispose() { + this.disposed = true; this.promptMarkers.forEach((marker) => { try { marker.dispose(); @@ -450,6 +464,7 @@ export class TermWrap { } }); this.promptMarkers = []; + this.cancelPendingCompositionSuffix(); this.webglContextLossDisposable?.dispose(); this.webglContextLossDisposable = null; this.terminal.dispose(); @@ -463,15 +478,139 @@ export class TermWrap { this.mainFileSubject.release(); } - handleTermData(data: string) { - if (!this.loaded) { + registerCompositionEventHandlers() { + const textarea = this.terminal.textarea; + if (textarea == null) { return; } + const compositionStartHandler = () => { + this.compositionActive = true; + this.compositionRecentlyEndedUntil = 0; + this.flushPendingCompositionSuffix(); + }; + const compositionEndHandler = () => { + this.compositionActive = false; + this.compositionRecentlyEndedUntil = Date.now() + 75; + }; + textarea.addEventListener("compositionstart", compositionStartHandler); + textarea.addEventListener("compositionend", compositionEndHandler); + this.toDispose.push({ + dispose: () => { + textarea.removeEventListener("compositionstart", compositionStartHandler); + textarea.removeEventListener("compositionend", compositionEndHandler); + this.cancelPendingCompositionSuffix(); + }, + }); + } + shouldBypassWaveKeydownForComposition(event: KeyboardEvent): boolean { + if (this.compositionActive) { + return true; + } + if (Date.now() > this.compositionRecentlyEndedUntil) { + return false; + } + return !event.ctrlKey && !event.metaKey && !event.altKey && this.isCompositionSuffixData(event.key); + } + + sendTermData(data: string) { this.sendDataHandler?.(data); this.multiInputCallback?.(data); } + flushPendingCompositionSuffix() { + if (this.pendingCompositionSuffix == null) { + return; + } + const pendingData = this.pendingCompositionSuffix.data; + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix = null; + if (!this.loaded || this.disposed) { + return; + } + this.sendTermData(pendingData); + } + + cancelPendingCompositionSuffix() { + if (this.pendingCompositionSuffix == null) { + return; + } + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix = null; + } + + isLikelyCompositionText(data: string): boolean { + if (data.length === 0) { + return false; + } + let hasNonAscii = false; + for (const ch of data) { + const codePoint = ch.codePointAt(0); + if (codePoint == null || codePoint <= 0x1f || codePoint === 0x7f) { + return false; + } + if (codePoint > 0x7f) { + hasNonAscii = true; + } + } + return hasNonAscii; + } + + isCompositionSuffixData(data: string): boolean { + if (data.length === 0) { + return false; + } + for (const ch of data) { + const codePoint = ch.codePointAt(0); + if (codePoint == null || codePoint < 0x20 || codePoint > 0x7e) { + return false; + } + } + return true; + } + + handleTermData(data: string) { + if (!this.loaded) { + return; + } + + if (this.pendingCompositionSuffix != null) { + if (this.isLikelyCompositionText(data)) { + const pendingData = this.pendingCompositionSuffix.data; + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix = null; + this.sendTermData(data); + this.sendTermData(pendingData); + return; + } + if (this.isCompositionSuffixData(data) && Date.now() <= this.compositionRecentlyEndedUntil) { + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix.data += data; + this.pendingCompositionSuffix.timeout = setTimeout(() => { + this.flushPendingCompositionSuffix(); + }, 30); + return; + } + this.flushPendingCompositionSuffix(); + } + + if ( + this.isCompositionSuffixData(data) && + !this.compositionActive && + Date.now() <= this.compositionRecentlyEndedUntil + ) { + this.pendingCompositionSuffix = { + data, + timeout: setTimeout(() => { + this.flushPendingCompositionSuffix(); + }, 30), + }; + return; + } + + this.sendTermData(data); + } + addFocusListener(focusFn: () => void) { this.terminal.textarea.addEventListener("focus", focusFn); } diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..49f99b2a16 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -2052,6 +2052,7 @@ declare global { code: string; repeat?: boolean; location?: number; + isComposing?: boolean; shift?: boolean; control?: boolean; alt?: boolean; diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index 867dfcb4e2..4a5a789388 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -240,6 +240,7 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve rtn.key = event.key; rtn.location = event.location; (rtn as any).nativeEvent = event; + rtn.isComposing = event.isComposing || (event as any).keyCode == 229; if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { rtn.type = event.type; } else { @@ -268,6 +269,7 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { rtn.location = event.location; rtn.code = event.code; rtn.key = event.key; + rtn.isComposing = event.isComposing || event.keyCode == 229; return rtn; } diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go index 9ff5a4157e..3833aa3926 100644 --- a/pkg/vdom/vdom_types.go +++ b/pkg/vdom/vdom_types.go @@ -236,6 +236,8 @@ type WaveKeyboardEvent struct { Code string `json:"code"` // KeyboardEvent.code Repeat bool `json:"repeat,omitempty"` Location int `json:"location,omitempty"` // KeyboardEvent.location + // True while an IME composition is active. These key events should not trigger app shortcuts. + IsComposing bool `json:"isComposing,omitempty"` // modifiers Shift bool `json:"shift,omitempty"` diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index d8847cabf2..f9ed4b5957 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -32,6 +32,8 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": true, + "term:disablewebgl": true, + "term:fontfamily": "Hack, 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', 'Noto Sans CJK KR', 'Noto Sans CJK JP', 'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Apple SD Gothic Neo', 'Hiragino Sans', 'PingFang SC', 'PingFang TC', 'Microsoft YaHei', 'Malgun Gothic', monospace", "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false,