Skip to content

Commit 7f100d5

Browse files
committed
refactor: 显示形态收敛前端 (decodeDisplay 统一控制图片/CP437 符号化, MCP 缓冲回归纯文本, Rust decode_bytes 回归纯解码)
1 parent 934a29a commit 7f100d5

3 files changed

Lines changed: 133 additions & 10 deletions

File tree

src/js/receive.js

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { invoke } from '@tauri-apps/api/core';
22
import { listen } from '@tauri-apps/api/event';
3-
import { timestamp, bytesToHex, getSettings } from './utils.js';
3+
import { timestamp, bytesToHex, getSettings, decodeDisplay } from './utils.js';
44
import { termWrite, clearTerminal } from './terminal.js';
55
import { t } from './i18n.js';
66
import { ReceiveZoom } from './zoom.js';
@@ -38,6 +38,7 @@ let prevFrameRaw = '';
3838

3939
let openFrame = null;
4040
let frameText = '';
41+
let framePure = ''; // pure decoded text of the current frame (MCP buffer)
4142

4243
let contextMenu = null;
4344

@@ -370,12 +371,14 @@ function applyFrameActions(actions) {
370371
openFrame = buildFrameEl(a.marker, '');
371372
receiveContent.appendChild(openFrame);
372373
frameText = '';
374+
framePure = '';
373375
}
374376
} else if (a.type === 'frame-append') {
375377
if (!openFrame) {
376378
openFrame = buildFrameEl(null, '');
377379
receiveContent.appendChild(openFrame);
378380
frameText = '';
381+
framePure = '';
379382
}
380383
openFrame.appendChild(document.createTextNode(a.text));
381384
frameText += a.text;
@@ -393,8 +396,10 @@ function finalizeFrame() {
393396
if (!openFrame) return;
394397
const line = openFrame;
395398
const raw = frameText;
399+
const pure = framePure;
396400
openFrame = null;
397401
frameText = '';
402+
framePure = '';
398403

399404
if (foldEnabled) {
400405
if (foldActive && foldBadge && raw === foldText) {
@@ -420,7 +425,7 @@ function finalizeFrame() {
420425
const badge = createFoldBadge(marker, raw, frameRepeat);
421426
receiveContent.appendChild(badge);
422427
for (let i = 0; i < frameRepeat - 1; i++) mcpBuffer.pop();
423-
mcpBuffer.push(badge.textContent);
428+
mcpBuffer.push(pure);
424429
foldActive = true;
425430
foldText = raw;
426431
foldCount = frameRepeat;
@@ -436,7 +441,7 @@ function finalizeFrame() {
436441
prevFrameRaw = raw;
437442

438443
if (filterText && !matchesFilter(raw)) line.style.display = 'none';
439-
mcpBuffer.push(raw);
444+
mcpBuffer.push(pure);
440445
evictIfNeeded();
441446
if (autoScroll) receiveArea.scrollTop = receiveArea.scrollHeight;
442447
}
@@ -450,12 +455,14 @@ function appendEchoFrame(text, marker, alreadyHex = false) {
450455
if (!echoEnabled) return;
451456
const display = alreadyHex
452457
? text
453-
: (hexDisplay ? bytesToHex(Array.from(new TextEncoder().encode(text))) : text);
458+
: (hexDisplay
459+
? bytesToHex(Array.from(new TextEncoder().encode(text)))
460+
: decodeDisplay(Array.from(new TextEncoder().encode(text)), encoding));
454461
const line = buildFrameEl(marker, '');
455462
receiveContent.appendChild(line);
456463
line.appendChild(document.createTextNode(display));
457464
if (filterText && !matchesFilter(display)) line.style.display = 'none';
458-
mcpBuffer.push(display);
465+
mcpBuffer.push(text);
459466
evictIfNeeded();
460467
if (autoScroll) receiveArea.scrollTop = receiveArea.scrollHeight;
461468
}
@@ -472,12 +479,17 @@ export async function appendData({ bytes, frameEnd }, direction) {
472479
termWrite(text);
473480

474481
// Debug view is the mirror: one frame = one div, raw content, long frames
475-
// are flushed progressively (256B chunks, see FrameLayout).
476-
// Direction marker is decoupled from the timestamp toggle: [R] / [T]
477-
// always shown, timestamps append the time when enabled.
482+
// are flushed progressively (256B chunks, see FrameLayout). Display form
483+
// (hex / control pictures / CP437) is a frontend-only concern — the MCP
484+
// buffer receives the pure decoded text.
478485
const marker = showTimestamp ? `[${direction}-${timestamp()}]` : '[R]';
479-
const frameText = hexDisplay ? bytesToHex(bytes) : text;
480-
applyFrameActions(layout.push(frameText, { frameEnd: !!frameEnd, marker }));
486+
const actions = layout.push(
487+
hexDisplay ? bytesToHex(bytes) : decodeDisplay(bytes, encoding),
488+
{ frameEnd: !!frameEnd, marker },
489+
);
490+
if (actions.some(a => a.type === 'frame-start')) framePure = '';
491+
framePure += text;
492+
applyFrameActions(actions);
481493
}
482494

483495
function appendSentText(text, isHex) {
@@ -585,6 +597,7 @@ function clearReceiveLines() {
585597
}
586598
openFrame = null;
587599
frameText = '';
600+
framePure = '';
588601
layout.reset();
589602
foldActive = false;
590603
foldBadge = null;

src/js/utils.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,79 @@ export function bytesToHex(bytes) {
8888
return bytes.map(b => b.toString(16).toUpperCase().padStart(2, '0')).join(' ');
8989
}
9090

91+
/// CP437 glyphs for bytes 0x80..=0xFF: every high byte gets a displayable
92+
/// symbol (box drawing, block chars, greek, math) instead of a tofu box.
93+
const CP437 = [
94+
'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å',
95+
'É', 'æ', 'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ',
96+
'á', 'í', 'ó', 'ú', 'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»',
97+
'░', '▒', '▓', '│', '┤', '╡', '╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐',
98+
'└', '┴', '┬', '├', '─', '┼', '╞', '╟', '╚', '╔', '╩', '╦', '╠', '═', '╬', '╧',
99+
'╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘', '┌', '█', '▄', '▌', '▐', '▀',
100+
'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ', '∞', 'φ', 'ε', '∩',
101+
'≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²', '■', '\u00a0',
102+
];
103+
104+
function utf8Len(b) {
105+
if (b >= 0xc2 && b <= 0xdf) return 2;
106+
if (b >= 0xe0 && b <= 0xef) return 3;
107+
if (b >= 0xf0 && b <= 0xf4) return 4;
108+
return 0;
109+
}
110+
111+
/// Display-form decode for the debug mirror (human eyes only; MCP/backend
112+
/// keep raw decoded text). Walks the raw bytes: ASCII stays as-is, control
113+
/// chars become control pictures (␀..␟, ␡), valid UTF-8/GBK sequences are
114+
/// decoded to text, and bytes that form no valid sequence render as CP437
115+
/// symbols. Pure frontend concern — nothing here feeds the terminal or MCP.
116+
export function decodeDisplay(bytes, encoding) {
117+
const gbk = encoding === 'gbk';
118+
const dec = new TextDecoder(gbk ? 'gbk' : 'utf-8');
119+
const u8 = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
120+
let out = '';
121+
let i = 0;
122+
while (i < u8.length) {
123+
const b = u8[i];
124+
if (b < 0x80) {
125+
if (b < 0x20) {
126+
out += String.fromCodePoint(0x2400 + b);
127+
} else if (b === 0x7f) {
128+
out += '\u2421';
129+
} else {
130+
out += String.fromCharCode(b);
131+
}
132+
i += 1;
133+
continue;
134+
}
135+
let seqLen = 0;
136+
if (gbk) {
137+
if (b >= 0x81 && b <= 0xfe && i + 1 < u8.length) {
138+
const t = u8[i + 1];
139+
if ((t >= 0x40 && t <= 0x7e) || (t >= 0x80 && t <= 0xfe)) seqLen = 2;
140+
}
141+
} else {
142+
seqLen = utf8Len(b);
143+
if (seqLen > 0 && i + seqLen <= u8.length) {
144+
for (let k = 1; k < seqLen; k++) {
145+
const c = u8[i + k];
146+
if (c < 0x80 || c > 0xbf) { seqLen = 0; break; }
147+
}
148+
}
149+
}
150+
if (seqLen > 0) {
151+
const s = dec.decode(u8.subarray(i, i + seqLen));
152+
if (!s.includes('\uFFFD')) {
153+
out += s;
154+
i += seqLen;
155+
continue;
156+
}
157+
}
158+
out += CP437[b - 0x80];
159+
i += 1;
160+
}
161+
return out;
162+
}
163+
91164
export function timestamp() {
92165
const d = new Date();
93166
const h = String(d.getHours()).padStart(2, '0');

tests/symbolize-controls.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { decodeDisplay } from '../src/js/utils.js';
3+
4+
const enc = (s) => Array.from(new TextEncoder().encode(s));
5+
6+
describe('decodeDisplay — 调试镜像显示形态(前端专用)', () => {
7+
it('keeps ASCII text as-is', () => {
8+
expect(decodeDisplay(enc('hello 123'), 'utf-8')).toBe('hello 123');
9+
});
10+
11+
it('maps control chars to control pictures', () => {
12+
expect(decodeDisplay(enc('\x1b[32m'), 'utf-8')).toBe('\u241b[32m');
13+
expect(decodeDisplay(enc('\r\n'), 'utf-8')).toBe('\u240d\u240a');
14+
expect(decodeDisplay(enc('\x00'), 'utf-8')).toBe('\u2400');
15+
expect(decodeDisplay(enc('\x7f'), 'utf-8')).toBe('\u2421');
16+
});
17+
18+
it('decodes valid UTF-8 multibyte sequences', () => {
19+
expect(decodeDisplay(enc('中文'), 'utf-8')).toBe('中文');
20+
expect(decodeDisplay(enc('a中b'), 'utf-8')).toBe('a中b');
21+
});
22+
23+
it('symbolizes invalid high bytes with CP437', () => {
24+
expect(decodeDisplay([0x41, 0x80, 0xb3, 0xff], 'utf-8')).toBe('AÇ│\u00a0');
25+
// invalid continuation after a valid lead is symbolized per byte
26+
expect(decodeDisplay([0xe4, 0x41], 'utf-8')).toBe('ΣA');
27+
});
28+
29+
it('decodes GBK text and symbolizes residual bytes', () => {
30+
// 中文 in GBK = D6 D0 CE C4; plus a lone high byte with no trail
31+
expect(decodeDisplay([0xd6, 0xd0, 0xce, 0xc4, 0x80], 'gbk')).toBe('中文Ç');
32+
});
33+
34+
it('symbolizes GBK lead without valid trail', () => {
35+
expect(decodeDisplay([0x81, 0x20], 'gbk')).toBe('ü ');
36+
});
37+
});

0 commit comments

Comments
 (0)