diff --git a/products/maka/workbench/app/ToolReader.tsx b/products/maka/workbench/app/ToolReader.tsx index 33e7185322..cdcfab8897 100644 --- a/products/maka/workbench/app/ToolReader.tsx +++ b/products/maka/workbench/app/ToolReader.tsx @@ -33,6 +33,7 @@ import { controlStyle, } from './ui'; import { Icon } from './Icon'; +import { codePieces } from './codePieces'; function Copy(props: { text(): string; label: string; source: ToolSource }) { const feedback = signal<{ text: string; error: string } | null>(null); @@ -148,30 +149,36 @@ function CodeText(props: { } > - - - - {props.gutter} - - - - - - $ - - {props.text} - + + + {(piece) => ( + + + + {piece().gutter} + + + + + + $ + + {piece().text} + + + )} + diff --git a/products/maka/workbench/app/codePieces.ts b/products/maka/workbench/app/codePieces.ts new file mode 100644 index 0000000000..9ab06a9197 --- /dev/null +++ b/products/maka/workbench/app/codePieces.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { tr } from './locale'; + +// macOS draws each text into one Core Animation layer, and a layer taller or +// wider than the GPU's largest texture (16384 pixels) shows nothing. Code text +// is laid out in pieces of 200 lines, and a line too wide for one layer is cut. +// Both stay well under the limit at 2x. Copy buttons still copy the whole text. +export const pieceLines = 200; +export const lineColumns = 800; + +export type CodePiece = { index: number; text: string; gutter?: string | undefined }; + +// Wide characters (East Asian, emoji) take two columns in a monospace font. +const width = (char: string) => (char.codePointAt(0)! >= 0x1100 ? 2 : 1); + +function cut(line: string) { + // A line of this many code units cannot be wider than the limit. + if (line.length <= lineColumns / 2) return line; + let used = 0; + let end = 0; + for (const char of line) { + used += width(char); + if (used > lineColumns) break; + end += char.length; + } + if (end === line.length) return line; + const rest = [...line.slice(end)].length; + return `${line.slice(0, end)} ${tr('… {count} more characters', { count: String(rest) })}`; +} + +export function codePieces(text: string, gutter?: string): CodePiece[] { + const lines = text.split('\n').map(cut); + if (lines.length <= pieceLines) return [{ index: 0, text: lines.join('\n'), gutter }]; + // Numbers padded to one width keep every piece's text in one column. + const split = gutter?.split('\n'); + const digits = Math.max(0, ...(split ?? []).map((mark) => mark.length)); + const marks = split?.map((mark) => mark.padStart(digits)); + const pieces: CodePiece[] = []; + for (let start = 0; start < lines.length; start += pieceLines) + pieces.push({ + index: pieces.length, + text: lines + .slice(start, start + pieceLines) + // A text drops an empty first or last line; a space keeps its height. + .map((line, at, all) => (line === '' && (at === 0 || at === all.length - 1) ? ' ' : line)) + .join('\n'), + gutter: marks?.slice(start, start + pieceLines).join('\n'), + }); + return pieces; +} diff --git a/products/maka/workbench/app/locale-conversation.ts b/products/maka/workbench/app/locale-conversation.ts index f4a6d580c4..78922e4e1a 100644 --- a/products/maka/workbench/app/locale-conversation.ts +++ b/products/maka/workbench/app/locale-conversation.ts @@ -689,6 +689,11 @@ export const conversationMessages = { '編輯佇列訊息 {number}', ], 'Empty recorded text.': ['Empty recorded text.', '记录的文本为空。', '記錄的文字為空。'], + '… {count} more characters': [ + '… {count} more characters', + '…还有 {count} 个字符', + '…還有 {count} 個字元', + ], 'Enter a complete URI, including its scheme.': [ 'Enter a complete URI, including its scheme.', '请输入完整 URI,包括协议。', diff --git a/products/maka/workbench/tests/code-pieces.test.mjs b/products/maka/workbench/tests/code-pieces.test.mjs new file mode 100644 index 0000000000..25b649a89e --- /dev/null +++ b/products/maka/workbench/tests/code-pieces.test.mjs @@ -0,0 +1,84 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import test from 'node:test'; + +const app = new URL('../app/', import.meta.url); +const locale = ` + export const tr = (key, values = {}) => + key.replace(/\\{(\\w+)\\}/g, (placeholder, name) => values[name] ?? placeholder); +`; +const hooks = registerHooks({ + resolve(specifier, context, next) { + if (context.parentURL?.startsWith(app.href) && specifier === './locale') + return { url: `data:text/javascript,${encodeURIComponent(locale)}`, shortCircuit: true }; + return next(specifier, context); + }, +}); +const { codePieces, lineColumns, pieceLines } = await import(new URL('codePieces.ts', app)); +hooks.deregister(); + +const numbered = (count) => Array.from({ length: count }, (_, index) => `line ${index + 1}`); + +test('short code text stays one piece, unchanged', () => { + const text = 'first\n\nlast\n'; + assert.deepEqual(codePieces(text, '1\n2\n3\n4'), [{ index: 0, text, gutter: '1\n2\n3\n4' }]); +}); + +test('long code text is split into pieces of 200 lines that keep every line', () => { + const lines = numbered(pieceLines * 2 + 5); + const gutter = lines.map((_, index) => String(index + 1)).join('\n'); + const pieces = codePieces(lines.join('\n'), gutter); + assert.deepEqual( + pieces.map((piece) => piece.text.split('\n').length), + [pieceLines, pieceLines, 5], + ); + assert.deepEqual( + pieces.flatMap((piece) => piece.text.split('\n')), + lines, + ); + // Line numbers are padded to the widest one, so every piece lines up. + assert.equal(pieces[0].gutter.split('\n')[0], ' 1'); + assert.equal(pieces[2].gutter.split('\n').at(-1), '405'); +}); + +test('an empty line at a piece edge keeps its height', () => { + const lines = numbered(pieceLines * 2); + lines[pieceLines - 1] = ''; + lines[pieceLines] = ''; + const pieces = codePieces(lines.join('\n')); + assert.equal(pieces[0].text.split('\n').at(-1), ' '); + assert.equal(pieces[1].text.split('\n')[0], ' '); +}); + +test('a line too wide for one layer is cut and says how much is left', () => { + const [piece] = codePieces(`${'a'.repeat(lineColumns + 30)}\nshort`); + assert.deepEqual(piece.text.split('\n'), [ + `${'a'.repeat(lineColumns)} … 30 more characters`, + 'short', + ]); + assert.equal(codePieces('a'.repeat(lineColumns))[0].text, 'a'.repeat(lineColumns)); +}); + +test('wide characters count two columns when a line is cut', () => { + const [piece] = codePieces('字'.repeat(lineColumns)); + assert.equal(piece.text, `${'字'.repeat(lineColumns / 2)} … ${lineColumns / 2} more characters`); +});