diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx index d68b4599e85..944afd63b51 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.test.tsx @@ -215,3 +215,38 @@ it('moves focus into the composer when a paste is routed from elsewhere', async expect(editor.view.dom.contains(document.activeElement)).toBe(true) hiddenTerminal.remove() }) + +describe('a pasted run too long to lay out with kerning', () => { + const unkerned = (container: HTMLElement) => + [...container.querySelectorAll('[style]')] + .filter((element) => element.style.fontKerning === 'none') + .map((element) => element.textContent) + + it('turns kerning off on that run only and keeps the pasted text whole', () => { + const { input, container } = setup('short line') + const blob = 'x'.repeat(5000) + act(() => { + input.setSelectionRange(10, 10) + insertNativeChatPastedText(input, `\nsee ${blob} here`) + }) + expect(unkerned(container)).toEqual([blob]) + expect(input.value).toBe(`short line\nsee ${blob} here`) + }) + + it('keeps kerning on prose, CJK and Shift+Enter lines, and never splits a CJK code point', () => { + const { editor, container } = setup('') + act(() => { + editor.commands.insertContent('a few words '.repeat(500)) + editor.commands.insertContent('日本語の文章です。中文的句子。'.repeat(100)) + for (let line = 0; line < 60; line++) { + editor.commands.insertContent('y'.repeat(70)) + editor.commands.setHardBreak() + } + editor.commands.insertContent('\u{20000}') + editor.commands.insertContent('x'.repeat(300)) + editor.commands.setHardBreak() + editor.commands.insertContent(`\u{20000}${'y'.repeat(300)}`) + }) + expect(unkerned(container)).toEqual(['x'.repeat(300), 'y'.repeat(300)]) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx index 7e7a81b3e5b..766c83a7142 100644 --- a/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx +++ b/src/renderer/src/components/native-chat/NativeChatPromptEditor.tsx @@ -38,6 +38,7 @@ import { import type { NativeChatComposerInput } from './native-chat-composer-input' import { NativeChatImageRowProvider } from './NativeChatImageReferencePill' import { mountedEditorView } from './native-chat-mounted-editor-view' +import { NativeChatUnkernedRunDecorations } from './native-chat-unkerned-run-decorations' type Props = Omit, 'onChange' | 'onSelect'> & { scopeKey?: string @@ -99,6 +100,7 @@ function NativePromptEditor({ NativeChatSkill, NativeChatFileReference, NativeChatImageReference, + NativeChatUnkernedRunDecorations, // A disabled box still says why it is disabled, as the plain text box's placeholder does. Placeholder.configure({ placeholder: () => placeholderRef.current, diff --git a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx index 99c23ed8409..e1bdb19a3e3 100644 --- a/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQueuedMessageCard.tsx @@ -41,6 +41,7 @@ import { NativeChatQueuedMessageComposer } from './NativeChatQueuedMessageCompos import type { QueuedEditAttachTarget } from './NativeChatQueuedMessageComposer' import type { QueuedMessageInlineEditor } from './use-structured-agent-session-queued-edit' import { nativeChatImageLabelCopy } from './native-chat-image-label' +import { NATIVE_CHAT_UNKERNED_CSS, nativeChatTextHasUnkernedRun } from './native-chat-unkerned-runs' /** The visible caption under the text; the default waiting hold needs none. */ export function queuedMessageCardCaption( @@ -344,6 +345,7 @@ export function NativeChatQueuedMessageCard({

{line}

diff --git a/src/renderer/src/components/native-chat/NativeChatUserMessageFold.tsx b/src/renderer/src/components/native-chat/NativeChatUserMessageFold.tsx index 1eb23f8ceaf..3382df85de0 100644 --- a/src/renderer/src/components/native-chat/NativeChatUserMessageFold.tsx +++ b/src/renderer/src/components/native-chat/NativeChatUserMessageFold.tsx @@ -4,6 +4,7 @@ import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import { useNativeChatDisclosure } from './native-chat-disclosure-store' import { nativeChatUserMessageFolds } from '../../../../shared/native-chat/transcript/native-chat-user-message-fold' +import { NATIVE_CHAT_UNKERNED_CSS, nativeChatTextHasUnkernedRun } from './native-chat-unkerned-runs' export function NativeChatUserMessageFold({ messageId, @@ -19,6 +20,8 @@ export function NativeChatUserMessageFold({ }): React.JSX.Element { const disclosure = useNativeChatDisclosure(`user-message:${messageId}`, false) const longText = useMemo(() => nativeChatUserMessageFolds(markdown), [markdown]) + // A fold clips but still lays out the whole text, so it needs the composer's long-run rule. + const unkerned = useMemo(() => nativeChatTextHasUnkernedRun(markdown), [markdown]) // Measure each text revision; long-text heuristics can select a prompt that already fits the clip. const [measured, setMeasured] = useState({ markdown, fits: false }) const fits = measured.markdown === markdown && measured.fits @@ -52,6 +55,7 @@ export function NativeChatUserMessageFold({ id={clipId} data-native-chat-user-message-folded={folded ? '' : undefined} className={cn(folded && 'max-h-44 overflow-hidden mask-b-from-[calc(100%-1.75rem)]')} + style={unkerned ? NATIVE_CHAT_UNKERNED_CSS : undefined} // Tabbing to a link under the fold scrolls the preview to it; the top returns once focus leaves. onBlur={(event) => { if (document.hasFocus() && !event.currentTarget.contains(event.relatedTarget)) { diff --git a/src/renderer/src/components/native-chat/native-chat-unkerned-run-decorations.test.ts b/src/renderer/src/components/native-chat/native-chat-unkerned-run-decorations.test.ts new file mode 100644 index 00000000000..dbc16daf75f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-unkerned-run-decorations.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import { Editor, type JSONContent } from '@tiptap/core' +import { EditorState, type Transaction } from '@tiptap/pm/state' +import { DecorationSet, type Decoration } from '@tiptap/pm/view' +import StarterKit from '@tiptap/starter-kit' +import { afterEach, expect, it } from 'vitest' +import { NativeChatUnkernedRunDecorations } from './native-chat-unkerned-run-decorations' +import { promptTextContent } from './native-chat-prompt-document' + +let editor: Editor | null = null +afterEach(() => { + editor?.destroy() + editor = null +}) + +function decorations(state: EditorState): Decoration[] { + return state.plugins.flatMap((plugin) => { + const set = plugin.props.decorations?.call(plugin, state) + return set instanceof DecorationSet ? set.find() : [] + }) +} + +const ranges = (state: EditorState) => decorations(state).map(({ from, to }) => [from, to]) + +// One block of long runs split by spaces and Shift+Enter breaks, as a pasted log can be. +function longRunBlock(runs: number): JSONContent { + return { + type: 'doc', + content: [ + { + type: 'paragraph', + content: Array.from({ length: runs }, (_, index): JSONContent[] => [ + { type: 'text', text: `${'x'.repeat(300)}${index % 2 ? ' ' : ''}` }, + ...(index % 2 ? [] : [{ type: 'hardBreak' }]) + ]).flat() + } + ] + } +} + +it('re-checks only the edited run of a long block and agrees with a full scan', () => { + editor = new Editor({ extensions: [StarterKit, NativeChatUnkernedRunDecorations] }) + const { plugins } = editor.extensionManager + const before = EditorState.create({ + doc: editor.schema.nodeFromJSON(longRunBlock(2000)), + plugins + }) + const old = decorations(before) + expect(old).toHaveLength(2000) + const edited = old[1000] + const after = before.apply(before.tr.insertText(' ', edited.from + 150)) + const kept = decorations(after) + expect(kept).toHaveLength(1999) + const oldSpecs = new Set(old.map((decoration) => decoration.spec)) + expect(kept.every((decoration) => oldSpecs.has(decoration.spec))).toBe(true) + expect(kept.some((decoration) => decoration.spec === edited.spec)).toBe(false) + expect(ranges(after)).toEqual(ranges(EditorState.create({ doc: after.doc, plugins }))) + + // Edits that complete or border a surrogate pair at the window's edge. + const x = (count: number) => 'x'.repeat(count) + const edits: [string, (state: EditorState) => Transaction][] = [ + ['\uD840', (state) => state.tr.insertText(`\uDC00${x(300)}`, 2)], + [`\uD840 \uDC00${x(300)}`, (state) => state.tr.delete(2, 3)], + [' \uDC00', (state) => state.tr.insertText(x(256), 3)] + ] + for (const [text, edit] of edits) { + const start = EditorState.create({ + doc: editor.schema.nodeFromJSON(promptTextContent(text)), + plugins + }) + const edited = start.apply(edit(start)) + expect(ranges(edited)).toEqual(ranges(EditorState.create({ doc: edited.doc, plugins }))) + } +}) + +it('waits for an IME composition to end before redecorating, then rescans', async () => { + const element = document.createElement('div') + editor = new Editor({ + element, + extensions: [StarterKit, NativeChatUnkernedRunDecorations], + content: `

${'x'.repeat(400)}

` + }) + const { view } = editor + const unkerned = () => element.querySelectorAll('[style*="font-kerning"]').length + expect(unkerned()).toBe(1) + view.dispatch(view.state.tr.insertText(' ', 200).setMeta('composition', 1)) + expect(unkerned()).toBe(1) + view.dom.dispatchEvent(new Event('compositionend')) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unkerned()).toBe(0) + expect(ranges(view.state)).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-unkerned-run-decorations.ts b/src/renderer/src/components/native-chat/native-chat-unkerned-run-decorations.ts new file mode 100644 index 00000000000..86b9c885b4a --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-unkerned-run-decorations.ts @@ -0,0 +1,162 @@ +import { Extension } from '@tiptap/core' +import type { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state' +import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { + NATIVE_CHAT_UNKERNED_STYLE, + isHighSurrogate, + isLowSurrogate, + nativeChatBreaksUnkernedRun, + nativeChatUnkernedRuns +} from './native-chat-unkerned-runs' + +type Range = [number, number] +type UnkernedState = { set: DecorationSet; pending: Range | null } + +function addRunDecorations(decorations: Decoration[], text: string, textPos: number): void { + for (const [start, end] of nativeChatUnkernedRuns(text)) { + // Not inclusive: an inclusive edge can map across a joined block's boundary, where it can't be removed. + decorations.push( + Decoration.inline(textPos + start, textPos + end, { style: NATIVE_CHAT_UNKERNED_STYLE }, {}) + ) + } +} + +function allDecorations(doc: ProseMirrorNode): DecorationSet { + const decorations: Decoration[] = [] + doc.descendants((node, pos) => { + if (node.isText) { + addRunDecorations(decorations, node.text ?? '', pos) + } + return !node.isInline + }) + return DecorationSet.create(doc, decorations) +} + +function union(a: Range | null, b: Range | null): Range | null { + return a && b ? [Math.min(a[0], b[0]), Math.max(a[1], b[1])] : (a ?? b) +} + +function changedHull(tr: Transaction): Range | null { + let hull: Range | null = null + tr.mapping.maps.forEach((map, index) => { + const rest = tr.mapping.slice(index + 1) + map.forEach((_oldFrom, _oldTo, from, to) => { + hull = union(hull, [rest.map(from, -1), rest.map(to, 1)]) + }) + }) + return hull +} + +/** Where the run through `pos` ends in direction `dir`; the text beyond `pos` is unchanged. */ +function runEdge(doc: ProseMirrorNode, set: DecorationSet, pos: number, dir: -1 | 1): number { + const $pos = doc.resolve(pos) + const node = dir < 0 ? $pos.nodeBefore : $pos.nodeAfter + if (!node?.isText || !node.text) { + return pos + } + // A decoration over the unchanged side already knows where that run ends. + const covering = set + .find(pos - 1, pos + 1) + .find((d) => (dir < 0 ? d.from < pos && d.to >= pos : d.to > pos && d.from <= pos)) + if (covering) { + return dir < 0 ? covering.from : covering.to + } + const { text } = node + if (dir < 0) { + for (let index = text.length - 1; index >= 0; index--) { + const code = text.charCodeAt(index) + // Ending on a supplementary ideograph's low surrogate, the run starts after the pair. + const high = text.charCodeAt(index - 1) + const pairEnd = + isLowSurrogate(code) && isHighSurrogate(high) && nativeChatBreaksUnkernedRun(high) + if (pairEnd || nativeChatBreaksUnkernedRun(code)) { + return pos - (text.length - 1 - index) + } + } + return pos - text.length + } + for (let index = 0; index < text.length; index++) { + if (nativeChatBreaksUnkernedRun(text.charCodeAt(index))) { + return pos + index + } + } + return pos + text.length +} + +// Why windowed: replacing every decoration in a block per keystroke is quadratic in its runs. +function rescan(doc: ProseMirrorNode, set: DecorationSet, [from, to]: Range): DecorationSet { + const size = doc.content.size + let left = runEdge(doc, set, Math.max(0, Math.min(from, size)), -1) + const $left = doc.resolve(left) + const before = $left.nodeBefore?.text ?? '' + // An edit can complete a surrogate pair at the window's edge; classify the pair whole. + if ( + isHighSurrogate(before.charCodeAt(before.length - 1)) && + isLowSurrogate(($left.nodeAfter?.text ?? '').charCodeAt(0)) + ) { + left = runEdge(doc, set, left - 1, -1) + } + const right = runEdge(doc, set, Math.max(0, Math.min(to, size)), 1) + const fresh: Decoration[] = [] + doc.nodesBetween(left, right, (node, pos) => { + if (node.isText) { + const start = Math.max(left, pos) + const end = Math.min(right, pos + node.nodeSize) + addRunDecorations(fresh, (node.text ?? '').slice(start - pos, end - pos), start) + } + return !node.isInline + }) + const stale = set.find(left, right).filter((d) => d.from < right && d.to > left) + const unchanged = + stale.length === fresh.length && + stale.every((old, index) => old.from === fresh[index].from && old.to === fresh[index].to) + return unchanged ? set : set.remove(stale).add(doc, fresh) +} + +/** Turns kerning off only on runs long enough to stall layout; the text is untouched. */ +export const NativeChatUnkernedRunDecorations = Extension.create({ + name: 'nativeChatUnkernedRunDecorations', + addProseMirrorPlugins() { + const key = new PluginKey('nativeChatUnkernedRunDecorations') + return [ + new Plugin({ + key, + state: { + init: (_, state) => ({ set: allDecorations(state.doc), pending: null }), + apply: (tr, previous) => { + const rescanRequested = tr.getMeta(key) === true + if (!tr.docChanged && !rescanRequested) { + return previous + } + const set = previous.set.map(tr.mapping, tr.doc) + const mappedPending: Range | null = previous.pending && [ + tr.mapping.map(previous.pending[0], -1), + tr.mapping.map(previous.pending[1], 1) + ] + const pending = union(mappedPending, changedHull(tr)) + // Why: redecorating mid-composition re-renders the IME's text node; rescan once it ends. + if (tr.getMeta('composition') !== undefined) { + return { set, pending } + } + return { set: pending ? rescan(tr.doc, set, pending) : set, pending: null } + } + }, + props: { + decorations: (state) => key.getState(state)?.set, + handleDOMEvents: { + // A composition can end with no further doc change, so ask for its rescan. + compositionend: (view) => { + setTimeout(() => { + if (!view.isDestroyed && !view.composing && key.getState(view.state)?.pending) { + view.dispatch(view.state.tr.setMeta(key, true).setMeta('addToHistory', false)) + } + }) + return false + } + } + } + }) + ] + } +}) diff --git a/src/renderer/src/components/native-chat/native-chat-unkerned-runs.ts b/src/renderer/src/components/native-chat/native-chat-unkerned-runs.ts new file mode 100644 index 00000000000..dcc601ddb2f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-unkerned-runs.ts @@ -0,0 +1,51 @@ +import type { CSSProperties } from 'react' + +// Why: Chromium reshapes a kerned run with no break opportunity at every wrap, quadratic in its +// length (1 MiB of "x": 19 s); past this length kerning goes, which keeps 1 MiB of such runs near 0.2 s. +const KERNED_RUN_MAX_CHARS = 256 + +export const NATIVE_CHAT_UNKERNED_STYLE = 'font-kerning: none' +export const NATIVE_CHAT_UNKERNED_CSS: CSSProperties = { fontKerning: 'none' } + +// Whitespace, plus scripts Chromium breaks between characters: Thai, Hangul, kana, CJK ideographs. +export function nativeChatBreaksUnkernedRun(code: number): boolean { + return ( + code === 32 || + (code >= 9 && code <= 13) || + (code >= 0x0e00 && code <= 0x0e7f) || + (code >= 0x1100 && code <= 0x11ff) || + (code >= 0x2e80 && code <= 0x9fff) || + (code >= 0xa960 && code <= 0xa97f) || + (code >= 0xac00 && code <= 0xd7af) || + // High surrogates of the supplementary ideograph planes (U+20000-U+3FFFF). + (code >= 0xd840 && code <= 0xd8bf) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xff00 && code <= 0xffef) + ) +} + +export const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff +export const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff + +/** `[start, end)` of every run with no break opportunity too long to lay out kerned; one linear pass. */ +export function nativeChatUnkernedRuns(text: string, limit = Infinity): [number, number][] { + const runs: [number, number][] = [] + let start = 0 + for (let index = 0; index <= text.length && runs.length < limit; index++) { + const code = text.charCodeAt(index) + if (index === text.length || nativeChatBreaksUnkernedRun(code)) { + if (index - start > KERNED_RUN_MAX_CHARS) { + runs.push([start, index]) + } + // A supplementary ideograph breaks as one code point, both surrogates. + const width = isHighSurrogate(code) && isLowSurrogate(text.charCodeAt(index + 1)) ? 2 : 1 + start = index + width + index += width - 1 + } + } + return runs +} + +export function nativeChatTextHasUnkernedRun(text: string): boolean { + return nativeChatUnkernedRuns(text, 1).length > 0 +}