Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 31 additions & 24 deletions products/maka/workbench/app/ToolReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -148,30 +149,36 @@ function CodeText(props: {
}
>
<Scroll horizontal onScrollIntent={props.userScroll}>
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<If when={props.gutter !== undefined}>
<Text
accessibilityElementsHidden
style={[
{ ...codeTextStyle, color: C.muted, textAlign: 'right' },
{ pointerEvents: 'none', userSelect: 'none' },
]}
>
{props.gutter}
</Text>
<View
style={[
{ width: 1, alignSelf: 'stretch', backgroundColor: C.line },
{ pointerEvents: 'none' },
]}
/>
</If>
<Text style={[{ ...codeTextStyle, color: color() }, { userSelect: 'text' }]}>
<If when={props.prompt}>
<Text style={[{ color: C.muted }, { userSelect: 'none' }]}>$ </Text>
</If>
{props.text}
</Text>
<View style={{ minWidth: 0 }}>
<Each of={codePieces(props.text, props.gutter)} key="index">
{(piece) => (
<View style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 12 }}>
<If when={piece().gutter !== undefined}>
<Text
accessibilityElementsHidden
style={[
{ ...codeTextStyle, color: C.muted, textAlign: 'right' },
{ pointerEvents: 'none', userSelect: 'none' },
]}
>
{piece().gutter}
</Text>
<View
style={[
{ width: 1, alignSelf: 'stretch', backgroundColor: C.line },
{ pointerEvents: 'none' },
]}
/>
</If>
<Text style={[{ ...codeTextStyle, color: color() }, { userSelect: 'text' }]}>
<If when={props.prompt && piece().index === 0}>
<Text style={[{ color: C.muted }, { userSelect: 'none' }]}>$ </Text>
</If>
{piece().text}
</Text>
</View>
)}
</Each>
</View>
</Scroll>
</If>
Expand Down
68 changes: 68 additions & 0 deletions products/maka/workbench/app/codePieces.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions products/maka/workbench/app/locale-conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,包括协议。',
Expand Down
84 changes: 84 additions & 0 deletions products/maka/workbench/tests/code-pieces.test.mjs
Original file line number Diff line number Diff line change
@@ -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`);
});
Loading