/** * terminal-core-view/index.js * * 微信小程序终端渲染组件。 * 消费 @remoteconn/terminal-core 构建产物(dist-miniprogram/index.js), * 通过 setData({rows, cursor}) 驱动 rich-text 渲染。 * * 外部属性(properties): * - gatewayUrl {string} 网关 WebSocket 地址 * - gatewayToken {string} 网关鉴权 Token * * 外部方法(通过组件引用调用): * - connect(params) 开始连接,params: { host, port, username, password? } * - disconnect(reason?) 断开连接 * - sendInput(data) 发送输入 */ const path = '../../../../packages/terminal-core/dist-miniprogram/index'; let TerminalCore, InputBridge, OutputBuffer, SessionMachine, sanitizeTerminalOutput, calcSize; try { const core = require(path); TerminalCore = core.TerminalCore; InputBridge = core.InputBridge; OutputBuffer = core.OutputBuffer; SessionMachine = core.SessionMachine; sanitizeTerminalOutput = core.sanitizeTerminalOutput; calcSize = core.calcSize; } catch(e) { console.error('[terminal-core-view] 无法加载 terminal-core,请先执行 build:miniprogram', e); } const { WxTransport } = require('../../utils/wxTransport'); const { createWxInputBridge } = require('../../utils/wxInputBridge'); const { createWxMeasureAdapter } = require('../../utils/wxMeasureAdapter'); // ── 颜色渲染 ────────────────────────────────────────────────────────────────── const ANSI16 = [ '#000000','#cc0000','#00aa00','#aaaa00', '#0000ee','#cc00cc','#00aaaa','#aaaaaa', '#555555','#ff5555','#55ff55','#ffff55', '#5555ff','#ff55ff','#55ffff','#ffffff', ]; function p256ToCss(idx) { if (idx < 16) return ANSI16[idx] || '#000'; if (idx >= 232) { const v = 8 + (idx - 232) * 10; return 'rgb(' + v + ',' + v + ',' + v + ')'; } const i = idx - 16; const b = i % 6, g = Math.floor(i / 6) % 6, r = Math.floor(i / 36); const ch = function(x) { return x === 0 ? 0 : 55 + x * 40; }; return 'rgb(' + ch(r) + ',' + ch(g) + ',' + ch(b) + ')'; } function colorToCss(c, isFg) { if (!c) return isFg ? 'inherit' : 'transparent'; switch (c.mode) { case 'default': return isFg ? 'inherit' : 'transparent'; case 'p16': return ANSI16[c.value] || 'inherit'; case 'p256': return p256ToCss(c.value); case 'rgb': return '#' + ((c.value & 0xffffff) | 0x1000000).toString(16).slice(1); default: return 'inherit'; } } const FLAG_BOLD = 1; const FLAG_DIM = 2; const FLAG_ITALIC = 4; const FLAG_UNDERLINE = 8; const FLAG_INVERSE = 16; const FLAG_INVISIBLE = 32; const FLAG_STRIKETHROUGH = 64; const FLAG_OVERLINE = 128; /** 将一行 cells 转换为 rich-text 节点数组 */ function lineToNodes(cells) { const nodes = []; let i = 0; while (i < cells.length) { const cell = cells[i]; if (!cell) { i++; continue; } const flags = cell.flags || 0; const inverse = !!(flags & FLAG_INVERSE); let fg = cell.fg, bg = cell.bg; if (inverse) { const tmp = fg; fg = bg; bg = tmp; } let style = ''; const fgCss = colorToCss(fg, true); const bgCss = colorToCss(bg, false); if (fgCss !== 'inherit') style += 'color:' + fgCss + ';'; if (bgCss !== 'transparent') style += 'background:' + bgCss + ';'; if (flags & FLAG_BOLD) style += 'font-weight:bold;'; if (flags & FLAG_DIM) style += 'opacity:0.6;'; if (flags & FLAG_ITALIC) style += 'font-style:italic;'; if (flags & FLAG_UNDERLINE) style += 'text-decoration:underline;'; if (flags & FLAG_STRIKETHROUGH) style += 'text-decoration:line-through;'; if (flags & FLAG_INVISIBLE) style += 'color:transparent;'; // 合并相邻相同 style 的 cells let text = cell.char || ' '; i++; while (i < cells.length) { const next = cells[i]; if (!next) break; const nFlags = next.flags || 0; // 简单比较:只有标志和颜色模式全相同才合并 if (nFlags !== flags || JSON.stringify(next.fg) !== JSON.stringify(fg) || JSON.stringify(next.bg) !== JSON.stringify(bg)) break; text += next.char || ' '; i++; } nodes.push({ type: 'node', name: 'span', attrs: style ? { style: style } : {}, children: [{ type: 'text', text: text }], }); } return nodes; } // ── 组件定义 ────────────────────────────────────────────────────────────────── Component({ properties: { gatewayUrl: { type: String, value: '' }, gatewayToken: { type: String, value: '' }, }, data: { rows: [], // Array<{ html: Array }> cursor: { row: -1, left: 0, visible: false }, scrollTop: 9999999, // 滚到底 state: 'idle', latencyMs: 0, title: '', inputValue: '', }, lifetimes: { attached: function() { this._init(); }, detached: function() { this._dispose(); }, }, methods: { // ── 初始化 ─────────────────────────────────────────────────────────────── _init: function() { const self = this; // Core self._core = TerminalCore ? new TerminalCore(80, 24) : null; self._buffer = OutputBuffer ? new OutputBuffer({ maxEntries: 500, maxBytes: 256 * 1024 }) : null; self._machine = SessionMachine ? new SessionMachine() : null; self._inputBridge = InputBridge ? new InputBridge() : null; self._wxBridge = createWxInputBridge(); self._transport = null; self._offTransport = null; self._lastRevision = -1; self._charW = 7.8; // 默认字符宽度 px (monospace 13px 近似) self._lineH = 16.25; // 行高 px (13px * 1.25) // 订阅 core 事件 if (self._core) { self._core.on('bell', function() { wx.vibrateShort({ type: 'light' }); }); self._core.on('titleChange', function(t) { self.setData({ title: t }); }); } // 测量适配器 self._measure = createWxMeasureAdapter(self, '#tc-viewport', self._charW, self._lineH); self._measure.onResize(function() { self._doFit(); }); // 输入桥接事件订阅 self._wxBridge.on('key', function(p) { if (!self._inputBridge) return; const seq = self._inputBridge.mapKey(p.key, p.code, p.ctrlKey, p.altKey, p.shiftKey, p.metaKey); if (seq !== null) self.sendInput(seq); }); self._wxBridge.on('input', function(p) { if (p.isComposing) return; if (p.data) self.sendInput(p.data); self.setData({ inputValue: '' }); }); self._wxBridge.on('paste', function(p) { if (!self._inputBridge) return; const seq = self._inputBridge.mapPaste(p.text); self.sendInput(seq); }); self._wxBridge.on('compositionend', function(p) { if (p.data) self.sendInput(p.data); self.setData({ inputValue: '' }); }); // 启动渲染循环(50ms ≈ 20fps,setData 有限流) self._renderTimer = setInterval(function() { self._renderFrame(); }, 50); // 启动测量轮询 self._measure.startPoll(600); }, _dispose: function() { if (this._renderTimer) { clearInterval(this._renderTimer); this._renderTimer = null; } this._measure && this._measure.stopPoll(); this._offTransport && this._offTransport(); this._transport && this._transport._cleanup && this._transport._cleanup(); }, // ── 尺寸适配 ───────────────────────────────────────────────────────────── _doFit: function() { if (!calcSize || !this._core) return; const { widthPx: contW, heightPx: contH } = this._measure.measureContainer(); if (contW < 10 || contH < 10) return; const { widthPx: charW, heightPx: lineH } = this._measure.measureChar(); const { cols, rows } = calcSize(contW, contH, charW, lineH); this._core.resize(cols, rows); this._charW = charW; if (this._transport && this.data.state === 'connected') { this._transport.resize(cols, rows); } }, // ── 渲染帧 ─────────────────────────────────────────────────────────────── _renderFrame: function() { if (!this._core) return; const snap = this._core.snapshot(); if (snap.revision === this._lastRevision) return; this._lastRevision = snap.revision; const rows = snap.lines.map(function(line) { return { html: lineToNodes(line.cells) }; }); // 光标位置(像素) const cursorLeft = snap.cursor.x * this._charW; const cursorRow = snap.cursor.y; // 相对可视区行号 this.setData({ rows: rows, cursor: { row: cursorRow, left: cursorLeft, visible: snap.cursor.visible }, title: snap.title || this.data.title, }); }, // ── 公开 API ────────────────────────────────────────────────────────────── connect: function(params) { const self = this; if (!WxTransport) { console.error('[tc-view] WxTransport 未加载'); return; } self._transport = new WxTransport({ gatewayUrl: self.properties.gatewayUrl, gatewayToken: self.properties.gatewayToken, }); self._offTransport = self._transport.on(function(event) { if (event.type === 'stdout' || event.type === 'stderr') { const sanitized = sanitizeTerminalOutput ? sanitizeTerminalOutput(event.data) : event.data; if (sanitized) { self._buffer && self._buffer.push(sanitized); self._core && self._core.write(sanitized); } } else if (event.type === 'control') { if (event.action === 'connected') { self._machine && self._machine.tryTransition('connected'); self.setData({ state: 'connected', latencyMs: 0 }); self._doFit(); } else if (event.action === 'pong') { // latency 由 WxTransport 内部 _pingAt 计算 } else if (event.action === 'disconnect') { self._machine && self._machine.tryTransition('disconnected'); self.setData({ state: 'disconnected' }); } } else if (event.type === 'error') { self._machine && self._machine.tryTransition('error'); self.setData({ state: 'error' }); } }); self._machine && self._machine.tryTransition('connecting'); self.setData({ state: 'connecting' }); self._transport.connect(params).catch(function(err) { console.error('[tc-view] connect error', err); self.setData({ state: 'error' }); }); }, disconnect: function(reason) { if (this._transport) { this._transport.disconnect(reason || 'manual'); } this._machine && this._machine.tryTransition('disconnected'); this.setData({ state: 'disconnected' }); }, sendInput: function(data) { if (this.data.state !== 'connected' || !this._transport) return; this._transport.send(data); }, // ── WXML 事件处理 ───────────────────────────────────────────────────────── onInput: function(e) { const value = e.detail.value || ''; const prev = this._prevInputValue || ''; // 计算增量字符 if (value.length > prev.length) { const delta = value.slice(prev.length); this._wxBridge.triggerInput(delta, e.detail.isComposing); } this._prevInputValue = value; }, onConfirm: function() { this._prevInputValue = ''; this._wxBridge.triggerConfirm(); }, onSend: function() { const val = this.data.inputValue; if (val) { this._wxBridge.triggerInput(val, false); this._wxBridge.triggerConfirm(); this.setData({ inputValue: '' }); this._prevInputValue = ''; } }, onInputBlur: function() { /* no-op */ }, onKeyboardChange: function() { /* no-op */ }, // ── 滚动事件 ────────────────────────────────────────────────────────────── onScrollToLower: function() { this._autoScroll = true; }, onScrollToUpper: function() { this._autoScroll = false; }, onScroll: function() { /* handled by upper/lower events */ }, // ── 触控工具栏 ──────────────────────────────────────────────────────────── onTapUp: function() { this._wxBridge.triggerKey('ArrowUp', 'ArrowUp'); }, onTapDown: function() { this._wxBridge.triggerKey('ArrowDown', 'ArrowDown'); }, onTapLeft: function() { this._wxBridge.triggerKey('ArrowLeft', 'ArrowLeft'); }, onTapRight: function() { this._wxBridge.triggerKey('ArrowRight', 'ArrowRight'); }, onTapTab: function() { this._wxBridge.triggerKey('Tab', 'Tab'); }, onTapEnter: function() { this._wxBridge.triggerConfirm(); }, onTapCtrlC: function() { this.sendInput('\u0003'); }, onTapCtrlD: function() { this.sendInput('\u0004'); }, onTapEsc: function() { this.sendInput('\x1b'); }, onTapPaste: function() { const self = this; wx.getClipboardData({ success: function(res) { if (res.data) self._wxBridge.triggerPaste(res.data); }, }); }, }, });