feat: VoiletXterm - Qt5 terminal workspace for SSH, Telnet and serial

Terminal core (src/core, QtCore only, headless testable):
- VT100/xterm parser: CSI/OSC/DCS, SGR 256/truecolor, scroll regions,
  insert/delete, DEC special graphics, alternate screen
- cell grid with 5000 line scrollback, CJK wide chars, combining marks
- xterm 256 color palette

Terminal widget (src/term):
- QPainter renderer with run batching, wide char clipping, cursor blink
- mouse selection (char/word/line), auto copy, middle click paste
- full key encoding: arrows + DECCKM, F1-F12, modifiers, ctrl/alt
- IME support via inputMethodEvent

Transports (src/session):
- SSH through the system OpenSSH client on a forkpty pseudo terminal,
  so agent, ProxyJump, known_hosts and 2FA keep working
- Telnet with IAC negotiation (NAWS, ECHO, SGA)
- Serial via QSerialPort

UI (src/ui):
- tabbed main window, session dialog with saved profiles
- dark violet theme, CLI targets (user@host:port, telnet://, serial:)

Tests:
- tests/test_core: escape sequence and layout assertions
- tests/test_widget: offscreen render checks and key encoding
This commit is contained in:
2026-10-23 17:38:16 +08:00
commit 580abbcadf
44 changed files with 5921 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
build/
.tmp/
Makefile
*.o
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
.qmake.stash
*.pro.user
*.autosave
.vscode/
+97
View File
@@ -0,0 +1,97 @@
# VoiletXterm
> 轻量的 SSH / Telnet / 串口终端工作台。Qt 5 + qmake,终端内核全部自研。
![screenshot](docs/screenshot.png)
MobaXterm 很好用,但只跑 Windows、源码闭源。VoiletXterm 只做三件事——**SSH、Telnet、串口**——用一套自己写的 VT100/xterm 内核把它们塞进同一个窗口,三平台共用一套 C++ 代码。
## 特性
**终端内核(自研,不依赖任何终端控件)**
- VT100/xterm 转义序列:CSI / OSC / DCS、SGR 256 色与真彩色、滚动区、插入/删除行列、DEC 特殊字符集
- 备用屏:`vim`、`htop`、`less` 正常工作;5000 行回滚
- CJK 宽字符、组合字符、彩色 emoji、粗体/斜体/下划线/删除线/反色
- 鼠标选区(单击拖动 / 双击选词 / 三击选行)、中键粘贴、自动复制
- 完整键位编码:方向键(含 DECCKM)、F1–F12、Home/End、`Ctrl+字母`、`Alt` 前缀、xterm 修饰符
- 中文输入法走 `inputMethodEvent`,候选窗跟随光标
**三种连接**
| 类型 | 实现 | 说明 |
| --- | --- | --- |
| SSH | 系统 `ssh` 跑在伪终端(pty)上 | `~/.ssh/config`、密钥、agent、`ProxyJump`、known_hosts、二次验证全部照常生效 |
| Telnet | 内置 IAC 协商 | 自动回 `WILL/DO`,支持 NAWS 窗口尺寸随窗口变化 |
| 串口 | QSerialPort | 波特率 / 数据位 / 校验 / 停止位 / 流控 / 本地回显 |
**界面**
- 多标签会话,标签标题跟随远端 `OSC 0/2` 标题
- 会话保存与复用(QSettings 持久化),新建/编辑/删除
- 命令行直达:`voiletxterm user@host:22` / `telnet://host:port` / `serial:/dev/ttyUSB0@115200`
- 深色紫罗兰主题,Ctrl+滚轮缩放字号
## 构建
需要 Qt 5.15(Widgets / Network / SerialPort 三个模块)。
```bash
export PATH=/path/to/Qt/5.15.2/gcc_64/bin:$PATH
mkdir -p build && cd build
qmake ../VoiletXterm.pro
make -j$(nproc)
./voiletxterm
```
串口需要读写权限,通常把用户加进 `dialout` 组:`sudo usermod -aG dialout $USER`。
## 使用
```bash
voiletxterm # 打开会话窗口
voiletxterm root@10.0.0.5:2222 # 直接 SSH
voiletxterm telnet://192.168.1.1 # 直接 Telnet
voiletxterm serial:/dev/ttyUSB0@115200
```
| 快捷键 | 作用 |
| --- | --- |
| `Ctrl+N` | 新建 SSH 会话 |
| `Ctrl+W` | 关闭当前标签 |
| `Ctrl+Shift+C` / `Ctrl+Shift+V` | 复制 / 粘贴 |
| `Ctrl++` / `Ctrl+-` / `Ctrl+0` | 放大 / 缩小 / 重置字号 |
## 结构
```
src/core/ Screen、Cell、VtParser、字符宽度、调色板 ← 只依赖 QtCore,可无头测试
src/term/ TerminalWidget(QPainter 渲染)、主题
src/session/ Transport 抽象、PtyProcess、SSH/Telnet/串口实现
src/ui/ MainWindow、SessionDialog、会话存储、样式
tests/ test_core(转义序列)、test_widget(离屏渲染 + 键位编码)
```
终端内核和界面完全解耦:`src/core` 只依赖 QtCore,测试可以直接跑在无显示环境里。
## 设计取舍
**为什么 SSH 用系统 `ssh` 而不是内嵌协议库?**
SSH 客户端最难的不是握手,而是把密钥、agent、跳板、known_hosts、二次验证这些琐事处理对。系统 OpenSSH 已经替我们做了二十年。VoiletXterm 用 `forkpty` 起一个 `ssh`,窗口尺寸通过 `TIOCSWINSZ` 同步,密码提示、主机指纹确认直接显示在终端里——所见即系统 ssh 的所见。
代价是依赖系统的 `ssh`(Linux/macOS 自带),以及无法做程序化的密码保管。
**为什么不用 qtermwidget?**
它是 GPL-2.0,一旦引入整个项目就得按 GPL 发布;而且 Konsole 血统的 QPainter 路径限制了性能上限。自研内核约 2000 行,MIT 无约束,问题也能自己修。
**宽字符对齐**
默认字体优先选半角/全角宽度严格 1:2 的字体(Sarasa Mono SC、文泉驿等宽正黑),这样 `中` 正好占两格;找不到再退回 DejaVu Sans Mono。
## 已知限制
- 只在 Linux 上验证过;`forkpty` 是 POSIX 接口,Windows 需要换成 ConPTY 才能编译
- 没有 SFTP 文件面板、没有 X11 转发、没有 RDP(MobaXterm 的那部分不在当前范围)
- 没有标签内搜索、没有分屏
- SSH 密码登录在终端里交互,不经过独立密码框
## 许可
MIT
+51
View File
@@ -0,0 +1,51 @@
QT += core gui widgets network serialport
TARGET = voiletxterm
TEMPLATE = app
CONFIG += c++17 warn_on
CONFIG -= app_bundle
DEFINES += QT_DEPRECATED_WARNINGS
INCLUDEPATH += src
SOURCES += \
src/main.cpp \
src/core/CharWidth.cpp \
src/core/Palette.cpp \
src/core/Screen.cpp \
src/core/VtParser.cpp \
src/term/TerminalTheme.cpp \
src/term/TerminalWidget.cpp \
src/session/PtyProcess.cpp \
src/session/SerialTransport.cpp \
src/session/SessionFactory.cpp \
src/session/SessionSpec.cpp \
src/session/SshTransport.cpp \
src/session/TelnetTransport.cpp \
src/ui/MainWindow.cpp \
src/ui/SessionDialog.cpp \
src/ui/SessionStore.cpp \
src/ui/Style.cpp
HEADERS += \
src/core/Cell.h \
src/core/CharWidth.h \
src/core/Palette.h \
src/core/Screen.h \
src/core/VtParser.h \
src/term/TerminalTheme.h \
src/term/TerminalWidget.h \
src/session/PtyProcess.h \
src/session/SerialTransport.h \
src/session/SessionFactory.h \
src/session/SessionSpec.h \
src/session/SshTransport.h \
src/session/TelnetTransport.h \
src/session/Transport.h \
src/ui/MainWindow.h \
src/ui/SessionDialog.h \
src/ui/SessionStore.h \
src/ui/Style.h
unix: LIBS += -lutil
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+50
View File
@@ -0,0 +1,50 @@
# ADR-0001:技术选型
- 状态:已采纳(2026-10)
- 替代:早先的「Tauri + Rust sidecar」提案
## 结论
| 层 | 决定 |
| --- | --- |
| UI 框架 | Qt 5.15 + qmake(Widgets,不用 QML) |
| 终端渲染 | 自研 `QWidget` + `QPainter`(Konsole 路线),抽象出 `Screen`/`VtParser` |
| SSH | 系统 `ssh` + `forkpty`,不自研协议 |
| Telnet | 自研 IAC 协商,`QTcpSocket` |
| 串口 | `QSerialPort` |
| 范围 | 只做 SSH / Telnet / 串口 |
## 背景
目标是用开源软件替代 MobaXterm。最初的方案是 Tauri + Rust sidecar(见下文对比),后来范围收敛到三个协议、并且明确只用 Qt 5,于是重新评估。
## 决策依据
**1. 终端渲染不是"画字"**
真正的难点是行内多属性分段、CJK 宽字符对齐、IME 候选窗定位、大流量下的帧合并。Qt 提供了 HarfBuzz 整形、字体回退、彩色 emoji、`inputMethodEvent`,前三点不用自己造。剩余工作量集中在 `Screen` 状态机和绘制循环,约 2000 行。
**2. SSH 的复杂度在"配置"而不是"协议"**
内嵌 libssh2 意味着:密钥格式、agent 转发、ProxyJump、known_hosts、键盘交互式认证、多因素,全都要自己实现一遍。直接跑系统 `ssh` 却是零成本——用户已有的配置原样生效。用 pty 包一层即可解决窗口尺寸和 SIGWINCH。
**3. 许可**
Qt 开源版是 LGPLv3:动态链接 + 允许替换库即可合规,本项目 MIT 开源天然满足。不能静态链接、不能用于 Mac App Store 的 DRM 场景;将来若闭源商业化需要商业许可。
qtermwidget 是 GPL-2.0,会传染整个项目,因此排除——这也是自研内核的直接原因。
**4. 为什么不用 Rust sidecar**
跨进程协议、打包、生命周期管理都是额外成本;在只做三个协议、单人开发的规模下,收益不足以抵消复杂度。这一点在团队/功能扩大(比如要 SFTP 并发传输、X11 转发)时可以重新评估——`Transport` 接口就是为此留的缝。
## 被否决的方案
| 方案 | 否决原因 |
| --- | --- |
| Tauri 2 + xterm.js | 依赖系统 WebView,三平台三个引擎;Linux 下 fcitx5/IME 有坑;终端选择/渲染要和 xterm.js 的内部模型搏斗 |
| QWebEngineView + xterm.js | 包体 +80MB,内存翻倍,等于把 Electron 装进 Qt |
| 内嵌 libssh2/libssh | 协议之外的配置成本极高,C 实现的攻击面也更大 |
| QSsh(Qt Creator 内部库) | 非公开模块,无 API 稳定性承诺,功能覆盖不足 |
## 后果
- 正面:单二进制、启动快、渲染可控、无第三方终端依赖、内核可无头测试
- 负面:`forkpty` 绑定 POSIX,Windows 需要单独实现(ConPTY)
- 负面:Qt 是单一供应商,遇到框架层 bug 只能等上游或绕路
- 待办:Windows 支持、SFTP 面板、标签内搜索、分屏
+71
View File
@@ -0,0 +1,71 @@
#pragma once
#include <QString>
#include <QtGlobal>
namespace vx {
// Color encoding
// 0x00000000 -> terminal default foreground/background
// 0x01RRGGBB -> direct 24-bit color
// 0x020000II -> index into the 256 color palette
using Color = quint32;
constexpr Color kColorDefault = 0u;
inline Color rgbColor(int r, int g, int b)
{
return 0x01000000u | (quint32(r & 0xFF) << 16) | (quint32(g & 0xFF) << 8) | quint32(b & 0xFF);
}
inline Color paletteColor(int index)
{
return 0x02000000u | quint32(index & 0xFF);
}
inline bool isDefaultColor(Color c) { return c == 0u; }
inline bool isRgbColor(Color c) { return (c & 0xFF000000u) == 0x01000000u; }
inline bool isPaletteColor(Color c) { return (c & 0xFF000000u) == 0x02000000u; }
inline int paletteIndexOf(Color c) { return int(c & 0xFFu); }
inline quint32 rgbValueOf(Color c) { return c & 0x00FFFFFFu; }
enum AttrFlag : quint16 {
AttrBold = 1u << 0,
AttrDim = 1u << 1,
AttrItalic = 1u << 2,
AttrUnderline = 1u << 3,
AttrBlink = 1u << 4,
AttrReverse = 1u << 5,
AttrHidden = 1u << 6,
AttrStrike = 1u << 7,
AttrOverline = 1u << 8,
AttrDoubleUnderline = 1u << 9,
AttrWide = 1u << 10, // first half of a double width character
AttrWideTail = 1u << 11, // second half of a double width character
};
// A single screen cell. Kept at 16 bytes so that scrollback stays affordable.
struct Cell {
char32_t ch = U' ';
Color fg = kColorDefault;
Color bg = kColorDefault;
quint16 attr = 0;
quint16 extra = 0; // 1-based index into Screen's grapheme table, 0 = none
bool isBlank() const { return ch == U' ' && attr == 0 && fg == kColorDefault && bg == kColorDefault && extra == 0; }
bool isWideHead() const { return (attr & AttrWide) != 0; }
bool isWideTail() const { return (attr & AttrWideTail) != 0; }
};
// Safe conversion of a stored code point (may be 0 for wide char tails).
inline QString codePointToString(char32_t cp)
{
if (cp == 0 || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF))
return QString(QChar(U' '));
uint value = uint(cp);
return QString::fromUcs4(&value, 1);
}
static_assert(sizeof(Cell) == 16, "Cell must stay compact");
} // namespace vx
+125
View File
@@ -0,0 +1,125 @@
#include "CharWidth.h"
namespace vx {
namespace {
struct Range {
char32_t lo;
char32_t hi;
};
constexpr Range kZeroWidth[] = {
{0x00AD, 0x00AD}, {0x0300, 0x036F}, {0x0483, 0x0489}, {0x0591, 0x05BD},
{0x05BF, 0x05BF}, {0x05C1, 0x05C2}, {0x05C4, 0x05C5}, {0x05C7, 0x05C7},
{0x0610, 0x061A}, {0x064B, 0x065F}, {0x0670, 0x0670}, {0x06D6, 0x06DC},
{0x06DF, 0x06E4}, {0x06E7, 0x06E8}, {0x06EA, 0x06ED}, {0x0711, 0x0711},
{0x0730, 0x074A}, {0x07A6, 0x07B0}, {0x07EB, 0x07F3}, {0x0816, 0x082D},
{0x0859, 0x085B}, {0x08D3, 0x0903}, {0x093A, 0x093C}, {0x0941, 0x0948},
{0x094D, 0x094D}, {0x0951, 0x0957}, {0x0962, 0x0963}, {0x0981, 0x0981},
{0x09BC, 0x09BC}, {0x09C1, 0x09C4}, {0x09CD, 0x09CD}, {0x0A01, 0x0A02},
{0x0A3C, 0x0A3C}, {0x0A41, 0x0A42}, {0x0A47, 0x0A48}, {0x0A4B, 0x0A4D},
{0x0A70, 0x0A71}, {0x0A81, 0x0A82}, {0x0ABC, 0x0ABC}, {0x0AC1, 0x0AC8},
{0x0ACD, 0x0ACD}, {0x0B01, 0x0B01}, {0x0B3C, 0x0B3C}, {0x0B3F, 0x0B3F},
{0x0B41, 0x0B44}, {0x0B4D, 0x0B4D}, {0x0B82, 0x0B82}, {0x0BC0, 0x0BC0},
{0x0BCD, 0x0BCD}, {0x0C00, 0x0C00}, {0x0C3E, 0x0C40}, {0x0C46, 0x0C48},
{0x0C4A, 0x0C4D}, {0x0CBC, 0x0CBC}, {0x0CCC, 0x0CCD}, {0x0D41, 0x0D44},
{0x0D4D, 0x0D4D}, {0x0DCA, 0x0DCA}, {0x0DD2, 0x0DD4}, {0x0E31, 0x0E31},
{0x0E34, 0x0E3A}, {0x0E47, 0x0E4E}, {0x0EB1, 0x0EB1}, {0x0EB4, 0x0EB9},
{0x0EBB, 0x0EBC}, {0x0EC8, 0x0ECD}, {0x0F18, 0x0F19}, {0x0F35, 0x0F35},
{0x0F37, 0x0F37}, {0x0F39, 0x0F39}, {0x0F71, 0x0F7E}, {0x0F80, 0x0F84},
{0x0F86, 0x0F87}, {0x0F8D, 0x0F97}, {0x0F99, 0x0FBC}, {0x0FC6, 0x0FC6},
{0x102D, 0x1030}, {0x1032, 0x1037}, {0x1039, 0x103A}, {0x1058, 0x1059},
{0x1160, 0x11FF}, {0x135D, 0x135F}, {0x1712, 0x1714}, {0x1732, 0x1734},
{0x1752, 0x1753}, {0x1772, 0x1773}, {0x17B4, 0x17B5}, {0x17B7, 0x17BD},
{0x17C6, 0x17C6}, {0x17C9, 0x17D3}, {0x17DD, 0x17DD}, {0x180B, 0x180D},
{0x18A9, 0x18A9}, {0x1920, 0x1922}, {0x1927, 0x1928}, {0x1932, 0x1932},
{0x1939, 0x193B}, {0x1A17, 0x1A18}, {0x1A1B, 0x1A1B}, {0x1A56, 0x1A56},
{0x1A60, 0x1A60}, {0x1A62, 0x1A62}, {0x1A65, 0x1A6C}, {0x1A73, 0x1A7C},
{0x1A7F, 0x1A7F}, {0x1AB0, 0x1AFF}, {0x1B00, 0x1B03}, {0x1B34, 0x1B34},
{0x1B36, 0x1B3A}, {0x1B3C, 0x1B3C}, {0x1B42, 0x1B42}, {0x1B6B, 0x1B73},
{0x1DC0, 0x1DFF}, {0x200B, 0x200F}, {0x2028, 0x202E}, {0x2060, 0x2064},
{0x2066, 0x206F}, {0x20D0, 0x20F0}, {0x2CEF, 0x2CF1}, {0x2D7F, 0x2D7F},
{0x302A, 0x302D}, {0x3099, 0x309A}, {0xA66F, 0xA672}, {0xA674, 0xA67D},
{0xA69E, 0xA69F}, {0xA6F0, 0xA6F1}, {0xA802, 0xA802}, {0xA806, 0xA806},
{0xA80B, 0xA80B}, {0xA825, 0xA826}, {0xA8C4, 0xA8C5}, {0xA8E0, 0xA8F1},
{0xA926, 0xA92D}, {0xA947, 0xA951}, {0xA980, 0xA982}, {0xA9B3, 0xA9B3},
{0xA9B6, 0xA9B9}, {0xA9BC, 0xA9BD}, {0xAA29, 0xAA2E}, {0xAA31, 0xAA32},
{0xAA35, 0xAA36}, {0xAA43, 0xAA43}, {0xAA4C, 0xAA4C}, {0xAAB0, 0xAAB0},
{0xAAB2, 0xAAB4}, {0xAAB7, 0xAAB8}, {0xAABE, 0xAABF}, {0xAAC1, 0xAAC1},
{0xAAEC, 0xAAED}, {0xAAF6, 0xAAF6}, {0xABE5, 0xABE5}, {0xABE8, 0xABE8},
{0xABED, 0xABED}, {0xFB1E, 0xFB1E}, {0xFE00, 0xFE0F}, {0xFE20, 0xFE2F},
{0xFEFF, 0xFEFF}, {0x101FD, 0x101FD}, {0x10A01, 0x10A0F}, {0x11001, 0x11001},
{0x11038, 0x11046}, {0x1D167, 0x1D169}, {0x1D17B, 0x1D182}, {0x1D185, 0x1D18B},
{0x1D1AA, 0x1D1AD}, {0xE0100, 0xE01EF},
};
constexpr Range kWide[] = {
{0x1100, 0x115F}, {0x231A, 0x231B}, {0x2329, 0x232A}, {0x23E9, 0x23EC},
{0x23F0, 0x23F0}, {0x23F3, 0x23F3}, {0x25FD, 0x25FE}, {0x2614, 0x2615},
{0x2648, 0x2653}, {0x267F, 0x267F}, {0x2693, 0x2693}, {0x26A1, 0x26A1},
{0x26AA, 0x26AB}, {0x26BD, 0x26BE}, {0x26C4, 0x26C5}, {0x26CE, 0x26CE},
{0x26D4, 0x26D4}, {0x26EA, 0x26EA}, {0x26F2, 0x26F3}, {0x26F5, 0x26F5},
{0x26FA, 0x26FA}, {0x26FD, 0x26FD}, {0x2705, 0x2705}, {0x270A, 0x270B},
{0x2728, 0x2728}, {0x274C, 0x274C}, {0x274E, 0x274E}, {0x2753, 0x2755},
{0x2757, 0x2757}, {0x2795, 0x2797}, {0x27B0, 0x27B0}, {0x27BF, 0x27BF},
{0x2B1B, 0x2B1C}, {0x2B50, 0x2B50}, {0x2B55, 0x2B55}, {0x2E80, 0x2EF3},
{0x2F00, 0x2FD5}, {0x2FF0, 0x2FFB}, {0x3000, 0x303E}, {0x3041, 0x3096},
{0x3099, 0x30FF}, {0x3105, 0x312D}, {0x3131, 0x318E}, {0x3190, 0x31BA},
{0x31C0, 0x31E3}, {0x31F0, 0x321E}, {0x3220, 0x3247}, {0x3250, 0x32FE},
{0x3300, 0x4DBF}, {0x4E00, 0xA48C}, {0xA490, 0xA4C6}, {0xA960, 0xA97C},
{0xAC00, 0xD7A3}, {0xF900, 0xFAFF}, {0xFE10, 0xFE19}, {0xFE30, 0xFE52},
{0xFE54, 0xFE66}, {0xFE68, 0xFE6B}, {0xFF01, 0xFF60}, {0xFFE0, 0xFFE6},
{0x16FE0, 0x16FE4}, {0x17000, 0x187F7}, {0x18800, 0x18CD5}, {0x1B000, 0x1B152},
{0x1B164, 0x1B167}, {0x1B170, 0x1B2FB}, {0x1F004, 0x1F004}, {0x1F0CF, 0x1F0CF},
{0x1F18E, 0x1F18E}, {0x1F191, 0x1F19A}, {0x1F200, 0x1F320}, {0x1F32D, 0x1F335},
{0x1F337, 0x1F37C}, {0x1F37E, 0x1F393}, {0x1F3A0, 0x1F3CA}, {0x1F3CF, 0x1F3D3},
{0x1F3E0, 0x1F3F0}, {0x1F3F4, 0x1F3F4}, {0x1F3F8, 0x1F43E}, {0x1F440, 0x1F440},
{0x1F442, 0x1F4FC}, {0x1F4FF, 0x1F53D}, {0x1F54B, 0x1F54E}, {0x1F550, 0x1F567},
{0x1F57A, 0x1F57A}, {0x1F595, 0x1F596}, {0x1F5A4, 0x1F5A4}, {0x1F5FB, 0x1F64F},
{0x1F680, 0x1F6C5}, {0x1F6CC, 0x1F6CC}, {0x1F6D0, 0x1F6D2}, {0x1F6D5, 0x1F6D7},
{0x1F6EB, 0x1F6EC}, {0x1F6F4, 0x1F6FC}, {0x1F7E0, 0x1F7EB}, {0x1F90C, 0x1F93A},
{0x1F93C, 0x1F945}, {0x1F947, 0x1F978}, {0x1F97A, 0x1F9CB}, {0x1F9CD, 0x1F9FF},
{0x1FA70, 0x1FA74}, {0x1FA78, 0x1FA7A}, {0x1FA80, 0x1FA86}, {0x1FA90, 0x1FAA8},
{0x1FAB0, 0x1FAB6}, {0x1FAC0, 0x1FAC2}, {0x1FAD0, 0x1FAD6}, {0x20000, 0x2FFFD},
{0x30000, 0x3FFFD},
};
template <typename T, int N>
bool inTable(const T (&table)[N], char32_t cp)
{
if (cp < table[0].lo || cp > table[N - 1].hi)
return false;
int lo = 0;
int hi = N - 1;
while (lo <= hi) {
const int mid = (lo + hi) / 2;
if (cp < table[mid].lo) {
hi = mid - 1;
} else if (cp > table[mid].hi) {
lo = mid + 1;
} else {
return true;
}
}
return false;
}
} // namespace
bool isZeroWidth(char32_t cp) { return inTable(kZeroWidth, cp); }
bool isWideChar(char32_t cp) { return inTable(kWide, cp); }
int charWidth(char32_t cp)
{
if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp < 0xA0))
return 0;
if (isZeroWidth(cp))
return 0;
if (isWideChar(cp))
return 2;
return 1;
}
} // namespace vx
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <cstdint>
namespace vx {
// Display width of a code point following the East Asian Width rules used by
// terminals: 0 for combining marks, 2 for wide (CJK / emoji) and 1 otherwise.
int charWidth(char32_t cp);
bool isZeroWidth(char32_t cp);
bool isWideChar(char32_t cp);
} // namespace vx
+33
View File
@@ -0,0 +1,33 @@
#include "Palette.h"
namespace vx {
namespace {
const quint32 kBase[16] = {
0x000000, 0xCD0000, 0x00CD00, 0xCDCD00, 0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5,
0x7F7F7F, 0xFF0000, 0x00FF00, 0xFFFF00, 0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF,
};
const int kCubeLevels[6] = {0, 95, 135, 175, 215, 255};
} // namespace
quint32 defaultPaletteColor(int index)
{
if (index < 0)
index = 0;
if (index < 16)
return kBase[index];
if (index < 232) {
const int i = index - 16;
const int r = kCubeLevels[i / 36];
const int g = kCubeLevels[(i / 6) % 6];
const int b = kCubeLevels[i % 6];
return quint32((r << 16) | (g << 8) | b);
}
const int v = 8 + (index - 232) * 10;
return quint32((v << 16) | (v << 8) | v);
}
} // namespace vx
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include <QtGlobal>
namespace vx {
// Standard xterm color for palette index 0..255 (0xRRGGBB).
quint32 defaultPaletteColor(int index);
} // namespace vx
+897
View File
@@ -0,0 +1,897 @@
#include "Screen.h"
#include "CharWidth.h"
#include <QDebug>
#include <QString>
#include <algorithm>
#include <utility>
namespace vx {
namespace {
// DEC Special Graphics (0x5F .. 0x7E), used by ncurses line drawing fallbacks.
const char32_t kDecGraphics[32] = {
0x0020, 0x25C6, 0x2592, 0x2409, 0x240C, 0x240D, 0x240A, 0x00B0,
0x00B1, 0x2424, 0x240B, 0x2518, 0x2510, 0x250C, 0x2514, 0x253C,
0x23BA, 0x23BB, 0x2500, 0x23BC, 0x23BD, 0x251C, 0x2524, 0x2534,
0x252C, 0x2502, 0x2264, 0x2265, 0x03C0, 0x2260, 0x00A3, 0x00B7,
};
} // namespace
Screen::Screen(QObject *parent)
: QObject(parent)
{
initialize(80, 24);
}
void Screen::initialize(int cols, int rows)
{
m_cols = std::max(1, cols);
m_rows = std::max(1, rows);
m_primary.clear();
m_primary.resize(m_rows);
m_alt.clear();
m_alt.resize(m_rows);
for (auto &row : m_primary)
resizeRow(row, m_cols);
for (auto &row : m_alt)
resizeRow(row, m_cols);
m_history.clear();
m_altActive = false;
m_cx = m_cy = 0;
m_pendingWrap = false;
m_top = 0;
m_bot = m_rows - 1;
m_fg = m_bg = kColorDefault;
m_attr = 0;
m_autowrap = true;
m_origin = false;
m_insert = false;
m_cursorVisible = true;
m_cursorKeysApp = false;
m_keypadApp = false;
m_reverseVideo = false;
m_bracketedPaste = false;
m_charset[0] = m_charset[1] = 0;
m_activeCharset = 0;
m_graphemes.clear();
}
void Screen::reset()
{
const int cols = m_cols;
const int rows = m_rows;
initialize(cols, rows);
emit titleChanged(QString());
}
QVector<QVector<Cell>> &Screen::grid()
{
return m_altActive ? m_alt : m_primary;
}
const QVector<QVector<Cell>> &Screen::grid() const
{
return m_altActive ? m_alt : m_primary;
}
Cell Screen::blankCell() const
{
Cell c;
c.bg = m_bg;
return c;
}
void Screen::resizeRow(QVector<Cell> &row, int cols)
{
row.resize(cols);
}
void Screen::resize(int cols, int rows)
{
cols = std::max(1, cols);
rows = std::max(1, rows);
if (cols == m_cols && rows == m_rows)
return;
const int oldRows = m_rows;
if (cols != m_cols) {
for (auto &row : m_primary) {
row.resize(cols);
// drop a wide tail that lost its head
if (!row.isEmpty() && row.last().isWideTail())
row.last() = Cell();
if (!row.isEmpty() && row.last().isWideHead())
row.last() = Cell();
}
for (auto &row : m_alt) {
row.resize(cols);
if (!row.isEmpty() && (row.last().isWideTail() || row.last().isWideHead()))
row.last() = Cell();
}
m_cols = cols;
if (m_cx >= m_cols)
m_cx = m_cols - 1;
}
if (rows != oldRows) {
if (!m_altActive) {
if (rows < oldRows) {
const int drop = oldRows - rows;
for (int i = 0; i < drop; ++i) {
if (!m_primary.isEmpty())
pushHistory(std::move(m_primary[0]));
if (!m_primary.isEmpty())
m_primary.remove(0);
}
m_cy -= drop;
} else {
const int add = rows - oldRows;
int taken = 0;
while (taken < add && !m_history.empty()) {
m_primary.prepend(std::move(m_history.back()));
m_history.pop_back();
++taken;
}
m_cy += taken;
m_primary.resize(rows);
for (int i = 0; i < m_primary.size(); ++i)
resizeRow(m_primary[i], m_cols);
}
}
m_alt.resize(rows);
for (auto &row : m_alt)
resizeRow(row, m_cols);
if (m_primary.size() < rows)
m_primary.resize(rows);
if (m_primary.size() > rows)
m_primary.resize(rows);
m_rows = rows;
m_cy = std::clamp(m_cy, 0, m_rows - 1);
m_cx = std::clamp(m_cx, 0, m_cols - 1);
m_top = 0;
m_bot = m_rows - 1;
m_pendingWrap = false;
}
}
void Screen::clearScrollback()
{
m_history.clear();
}
void Screen::pushHistory(QVector<Cell> &&row)
{
m_history.push_back(std::move(row));
while (int(m_history.size()) > m_historyLimit)
m_history.pop_front();
}
const QVector<Cell> &Screen::lineVector(int index) const
{
static const QVector<Cell> kEmpty;
if (index < 0)
return kEmpty;
const int hist = m_altActive ? 0 : int(m_history.size());
if (index < hist)
return m_history[std::size_t(index)];
const int r = index - hist;
const auto &g = grid();
if (r < 0 || r >= g.size())
return kEmpty;
return g[r];
}
const Cell *Screen::lineData(int index) const
{
const QVector<Cell> &v = lineVector(index);
return v.isEmpty() ? nullptr : v.constData();
}
QString Screen::graphemeText(quint16 ref) const
{
if (ref == 0 || ref > m_graphemes.size())
return QString();
return m_graphemes[ref - 1];
}
quint16 Screen::addGrapheme(quint16 previous, char32_t cp)
{
QString text;
if (previous != 0 && previous <= m_graphemes.size())
text = m_graphemes[previous - 1];
text += QString::fromUcs4(&cp, 1);
if (m_graphemes.size() >= 65534)
return previous;
m_graphemes.append(text);
return quint16(m_graphemes.size());
}
char32_t Screen::mapCharsetChar(char32_t cp) const
{
const int set = m_charset[m_activeCharset];
if (set == '0' && cp >= 0x5F && cp <= 0x7E)
return kDecGraphics[cp - 0x5F];
if (set == 'A' && cp == 0x23)
return 0x00A3;
return cp;
}
void Screen::putChar(char32_t cp)
{
if (cp < 0x20)
return;
cp = mapCharsetChar(cp);
int w = charWidth(cp);
if (w == 0) {
putCombining(cp);
return;
}
if (w < 0) {
cp = 0xFFFD;
w = 1;
}
if (m_cols < 2)
w = 1;
if (m_pendingWrap) {
m_cx = 0;
lineFeed();
m_pendingWrap = false;
}
if (w == 2 && m_cx >= m_cols - 1) {
if (m_autowrap) {
m_cx = 0;
lineFeed();
} else {
m_cx = m_cols - 2;
}
}
auto &line = grid()[m_cy];
if (line.size() != m_cols)
line.resize(m_cols);
if (m_insert) {
const int shift = std::min(w, m_cols - m_cx);
line.insert(m_cx, shift, blankCell());
line.resize(m_cols);
}
clearCellAt(m_cy, m_cx);
if (w == 2)
clearCellAt(m_cy, m_cx + 1);
Cell c;
c.ch = cp;
c.fg = m_fg;
c.bg = m_bg;
c.attr = m_attr;
if (w == 2)
c.attr |= AttrWide;
c.extra = 0;
line[m_cx] = c;
if (w == 2) {
Cell tail;
tail.ch = 0;
tail.fg = m_fg;
tail.bg = m_bg;
tail.attr = quint16(m_attr | AttrWideTail);
line[m_cx + 1] = tail;
}
m_lastChar = cp;
m_cx += w;
if (m_cx >= m_cols) {
m_cx = m_cols - 1;
m_pendingWrap = m_autowrap;
}
}
void Screen::putCombining(char32_t cp)
{
int col = m_cx;
if (!m_pendingWrap)
--col;
if (col < 0)
col = 0;
auto &line = grid()[m_cy];
if (col < line.size() && line[col].isWideTail() && col > 0)
--col;
if (col < 0 || col >= line.size())
return;
Cell &c = line[col];
if (c.ch == 0 && c.isWideTail())
return;
c.extra = addGrapheme(c.extra, cp);
}
void Screen::repeatLastChar(int count)
{
for (int i = 0; i < count; ++i)
putChar(m_lastChar);
}
void Screen::carriageReturn()
{
m_cx = 0;
m_pendingWrap = false;
}
void Screen::lineFeed()
{
m_pendingWrap = false;
if (m_cy == m_bot)
scrollUp(1);
else if (m_cy < m_rows - 1)
++m_cy;
}
void Screen::reverseIndex()
{
m_pendingWrap = false;
if (m_cy == m_top)
scrollDown(1);
else if (m_cy > 0)
--m_cy;
}
void Screen::backSpace()
{
m_pendingWrap = false;
if (m_cx > 0)
--m_cx;
}
void Screen::tab(int count)
{
m_pendingWrap = false;
for (int i = 0; i < count; ++i) {
const int next = ((m_cx / 8) + 1) * 8;
m_cx = std::min(next, m_cols - 1);
}
}
void Screen::backTab(int count)
{
m_pendingWrap = false;
for (int i = 0; i < count; ++i) {
const int prev = ((m_cx + 7) / 8 - 1) * 8;
m_cx = std::max(prev, 0);
}
}
void Screen::alignTest()
{
for (int r = 0; r < m_rows; ++r) {
auto &line = grid()[r];
for (int c = 0; c < m_cols; ++c) {
Cell cell;
cell.ch = U'E';
cell.fg = m_fg;
cell.bg = m_bg;
line[c] = cell;
}
}
m_cx = 0;
m_cy = 0;
m_pendingWrap = false;
}
void Screen::moveTo(int row, int col)
{
m_pendingWrap = false;
if (m_origin) {
row += m_top;
row = std::clamp(row, m_top, m_bot);
}
m_cy = std::clamp(row, 0, m_rows - 1);
m_cx = std::clamp(col, 0, m_cols - 1);
}
void Screen::cursorUp(int n)
{
m_pendingWrap = false;
const int floorRow = (m_cy >= m_top) ? m_top : 0;
m_cy = std::max(m_cy - n, floorRow);
}
void Screen::cursorDown(int n)
{
m_pendingWrap = false;
const int ceilRow = (m_cy <= m_bot) ? m_bot : m_rows - 1;
m_cy = std::min(m_cy + n, ceilRow);
}
void Screen::cursorLeft(int n)
{
m_pendingWrap = false;
m_cx = std::max(m_cx - n, 0);
}
void Screen::cursorRight(int n)
{
m_pendingWrap = false;
m_cx = std::min(m_cx + n, m_cols - 1);
}
void Screen::setCursorCol(int col)
{
m_pendingWrap = false;
m_cx = std::clamp(col, 0, m_cols - 1);
}
void Screen::setCursorRow(int row)
{
m_pendingWrap = false;
if (m_origin)
row = std::clamp(m_top + row, m_top, m_bot);
m_cy = std::clamp(row, 0, m_rows - 1);
}
void Screen::clearRow(int row)
{
if (row < 0 || row >= m_rows)
return;
auto &line = grid()[row];
line.resize(m_cols);
const Cell blank = blankCell();
for (int c = 0; c < line.size(); ++c)
line[c] = blank;
}
void Screen::clearCellAt(int row, int col)
{
if (row < 0 || row >= m_rows)
return;
auto &line = grid()[row];
if (col < 0 || col >= line.size())
return;
if (line[col].isWideHead() && col + 1 < line.size() && line[col + 1].isWideTail())
line[col + 1] = blankCell();
else if (line[col].isWideTail() && col > 0 && line[col - 1].isWideHead())
line[col - 1] = blankCell();
line[col] = blankCell();
}
void Screen::sanitizeWide(int row)
{
if (row < 0 || row >= m_rows)
return;
auto &line = grid()[row];
for (int c = 0; c < line.size(); ++c) {
if (line[c].isWideHead()) {
if (c + 1 < line.size() && line[c + 1].isWideTail())
++c;
else
line[c] = blankCell();
} else if (line[c].isWideTail()) {
line[c] = blankCell();
}
}
}
void Screen::eraseInDisplay(int mode)
{
switch (mode) {
case 0:
eraseInLine(0);
for (int r = m_cy + 1; r < m_rows; ++r)
clearRow(r);
break;
case 1:
eraseInLine(1);
for (int r = 0; r < m_cy; ++r)
clearRow(r);
break;
case 2:
for (int r = 0; r < m_rows; ++r)
clearRow(r);
break;
case 3:
clearScrollback();
break;
default:
break;
}
}
void Screen::eraseInLine(int mode)
{
int from = 0;
int to = m_cols;
if (mode == 0) {
from = m_cx;
} else if (mode == 1) {
to = std::min(m_cx + 1, m_cols);
} else if (mode == 2) {
from = 0;
to = m_cols;
} else {
return;
}
for (int c = from; c < to; ++c)
clearCellAt(m_cy, c);
m_pendingWrap = false;
}
void Screen::eraseChars(int count)
{
for (int i = 0; i < count && m_cx + i < m_cols; ++i)
clearCellAt(m_cy, m_cx + i);
}
void Screen::insertChars(int count)
{
if (count <= 0)
return;
count = std::min(count, m_cols - m_cx);
auto &line = grid()[m_cy];
line.insert(m_cx, count, blankCell());
line.resize(m_cols);
sanitizeWide(m_cy);
m_pendingWrap = false;
}
void Screen::deleteChars(int count)
{
if (count <= 0)
return;
count = std::min(count, m_cols - m_cx);
auto &line = grid()[m_cy];
line.remove(m_cx, count);
line.append(QVector<Cell>(count, blankCell()));
sanitizeWide(m_cy);
m_pendingWrap = false;
}
void Screen::insertLines(int count)
{
count = std::min(count, m_bot - m_cy + 1);
for (int i = 0; i < count; ++i) {
if (m_bot > m_cy)
grid()[m_bot] = std::move(grid()[m_bot - 1]);
for (int r = m_bot - 1; r > m_cy; --r)
grid()[r] = std::move(grid()[r - 1]);
clearRow(m_cy);
}
m_cx = 0;
m_pendingWrap = false;
}
void Screen::deleteLines(int count)
{
count = std::min(count, m_bot - m_cy + 1);
for (int i = 0; i < count; ++i) {
for (int r = m_cy; r < m_bot; ++r)
grid()[r] = std::move(grid()[r + 1]);
clearRow(m_bot);
}
m_cx = 0;
m_pendingWrap = false;
}
void Screen::scrollUpRegion(int top, int bottom, int count)
{
count = std::min(count, bottom - top + 1);
const bool toHistory = (top == 0 && bottom == m_rows - 1 && !m_altActive);
for (int i = 0; i < count; ++i) {
if (toHistory)
pushHistory(std::move(grid()[top]));
for (int r = top; r < bottom; ++r)
grid()[r] = std::move(grid()[r + 1]);
clearRow(bottom);
}
}
void Screen::scrollDownRegion(int top, int bottom, int count)
{
count = std::min(count, bottom - top + 1);
for (int i = 0; i < count; ++i) {
for (int r = bottom; r > top; --r)
grid()[r] = std::move(grid()[r - 1]);
clearRow(top);
}
}
void Screen::scrollUp(int count)
{
scrollUpRegion(m_top, m_bot, count);
m_pendingWrap = false;
}
void Screen::scrollDown(int count)
{
scrollDownRegion(m_top, m_bot, count);
m_pendingWrap = false;
}
void Screen::setMargins(int top, int bottom)
{
top = std::clamp(top, 0, m_rows - 1);
bottom = std::clamp(bottom, 0, m_rows - 1);
if (top >= bottom)
return;
m_top = top;
m_bot = bottom;
moveTo(0, 0);
}
void Screen::resetMargins()
{
m_top = 0;
m_bot = m_rows - 1;
m_cx = m_cy = 0;
m_pendingWrap = false;
}
void Screen::saveCursor()
{
m_savedCx = m_cx;
m_savedCy = m_cy;
m_savedFg = m_fg;
m_savedBg = m_bg;
m_savedAttr = m_attr;
m_savedOrigin = m_origin;
m_savedAutowrap = m_autowrap;
m_savedCharset[0] = m_charset[0];
m_savedCharset[1] = m_charset[1];
}
void Screen::restoreCursor()
{
m_cx = std::clamp(m_savedCx, 0, m_cols - 1);
m_cy = std::clamp(m_savedCy, 0, m_rows - 1);
m_fg = m_savedFg;
m_bg = m_savedBg;
m_attr = m_savedAttr;
m_origin = m_savedOrigin;
m_autowrap = m_savedAutowrap;
m_charset[0] = m_savedCharset[0];
m_charset[1] = m_savedCharset[1];
m_pendingWrap = false;
}
void Screen::setSgr(const QVector<int> &params)
{
const int n = params.size();
if (n == 0) {
m_fg = m_bg = kColorDefault;
m_attr = 0;
return;
}
for (int i = 0; i < n; ++i) {
const int v = params[i];
if (v == 0) {
m_fg = m_bg = kColorDefault;
m_attr = 0;
} else if (v == 1) {
m_attr |= AttrBold;
} else if (v == 2) {
m_attr |= AttrDim;
} else if (v == 3) {
m_attr |= AttrItalic;
} else if (v == 4) {
if (i + 1 < n && params[i + 1] == 0) {
m_attr &= ~quint16(AttrUnderline | AttrDoubleUnderline);
++i;
} else if (i + 1 < n && (params[i + 1] == 2 || params[i + 1] == 3 || params[i + 1] == 4 || params[i + 1] == 5)) {
m_attr |= AttrUnderline;
++i;
} else {
m_attr |= AttrUnderline;
}
} else if (v == 5 || v == 6) {
m_attr |= AttrBlink;
} else if (v == 7) {
m_attr |= AttrReverse;
} else if (v == 8) {
m_attr |= AttrHidden;
} else if (v == 9) {
m_attr |= AttrStrike;
} else if (v == 21) {
m_attr |= AttrDoubleUnderline;
} else if (v == 22) {
m_attr &= ~quint16(AttrBold | AttrDim);
} else if (v == 23) {
m_attr &= ~quint16(AttrItalic);
} else if (v == 24) {
m_attr &= ~quint16(AttrUnderline | AttrDoubleUnderline);
} else if (v == 25) {
m_attr &= ~quint16(AttrBlink);
} else if (v == 27) {
m_attr &= ~quint16(AttrReverse);
} else if (v == 28) {
m_attr &= ~quint16(AttrHidden);
} else if (v == 29) {
m_attr &= ~quint16(AttrStrike);
} else if (v >= 30 && v <= 37) {
m_fg = paletteColor(v - 30);
} else if (v == 38 || v == 48 || v == 58) {
Color color = kColorDefault;
bool ok = false;
if (i + 1 < n) {
const int kind = params[++i];
if (kind == 5 && i + 1 < n) {
color = paletteColor(params[++i]);
ok = true;
} else if (kind == 2 && i + 3 < n) {
color = rgbColor(params[i + 1], params[i + 2], params[i + 3]);
i += 3;
ok = true;
} else if (kind == 2 && i + 4 < n) {
color = rgbColor(params[i + 2], params[i + 3], params[i + 4]);
i += 4;
ok = true;
}
}
if (ok) {
if (v == 38)
m_fg = color;
else if (v == 48)
m_bg = color;
}
} else if (v == 39) {
m_fg = kColorDefault;
} else if (v >= 40 && v <= 47) {
m_bg = paletteColor(v - 40);
} else if (v == 49) {
m_bg = kColorDefault;
} else if (v == 53) {
m_attr |= AttrOverline;
} else if (v == 55) {
m_attr &= ~quint16(AttrOverline);
} else if (v >= 90 && v <= 97) {
m_fg = paletteColor(v - 90 + 8);
} else if (v >= 100 && v <= 107) {
m_bg = paletteColor(v - 100 + 8);
}
}
}
void Screen::setPrivateMode(int mode, bool on)
{
switch (mode) {
case 1:
m_cursorKeysApp = on;
break;
case 5:
m_reverseVideo = on;
break;
case 6:
m_origin = on;
moveTo(0, 0);
break;
case 7:
m_autowrap = on;
if (!on)
m_pendingWrap = false;
break;
case 12:
break;
case 25:
m_cursorVisible = on;
break;
case 47:
case 1047:
case 1049:
if (on != m_altActive) {
if (on) {
saveCursor();
m_altActive = true;
for (auto &row : m_alt)
std::fill(row.begin(), row.end(), Cell());
m_cx = m_cy = 0;
m_pendingWrap = false;
m_top = 0;
m_bot = m_rows - 1;
} else {
m_altActive = false;
restoreCursor();
m_top = 0;
m_bot = m_rows - 1;
}
}
break;
case 1048:
if (on)
saveCursor();
else
restoreCursor();
break;
case 1000:
case 1002:
case 1003:
case 1006:
case 1015:
if (on)
m_mouseMode = mode;
else if (m_mouseMode == mode)
m_mouseMode = 0;
break;
case 2004:
m_bracketedPaste = on;
break;
default:
break;
}
}
void Screen::setAnsiMode(int mode, bool on)
{
switch (mode) {
case 4:
m_insert = on;
break;
case 20:
break;
default:
break;
}
}
void Screen::setCharset(int slot, char designator)
{
if (slot < 0 || slot > 1)
return;
m_charset[slot] = (designator == 'B' || designator == 0) ? 0 : designator;
}
void Screen::selectCharset(int slot)
{
m_activeCharset = (slot == 0 || slot == 1) ? slot : 0;
}
void Screen::ringBell()
{
emit bell();
}
void Screen::setTitle(const QString &title)
{
emit titleChanged(title);
}
void Screen::setWorkingDirectory(const QString &dir)
{
emit workingDirectoryChanged(dir);
}
void Screen::requestClipboard(const QString &text)
{
emit clipboardRequested(text);
}
void Screen::reportDeviceAttributes()
{
emit response(QByteArrayLiteral("\033[?62;22c"));
}
void Screen::reportStatus(int mode)
{
if (mode == 5)
emit response(QByteArrayLiteral("\033[0n"));
else if (mode == 6)
reportCursorPosition();
}
void Screen::reportCursorPosition()
{
QByteArray out = "\033[";
out += QByteArray::number(m_cy + 1);
out += ';';
out += QByteArray::number(m_cx + 1);
out += 'R';
emit response(out);
}
} // namespace vx
+169
View File
@@ -0,0 +1,169 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVector>
#include <deque>
#include "Cell.h"
namespace vx {
// Holds the character grid, the scrollback and the terminal pen/modes.
// Byte stream parsing lives in VtParser, which drives this class.
class Screen : public QObject
{
Q_OBJECT
public:
explicit Screen(QObject *parent = nullptr);
void initialize(int cols, int rows);
int columns() const { return m_cols; }
int rows() const { return m_rows; }
int totalLines() const { return historyVisible() + m_rows; }
int historyCount() const { return int(m_history.size()); }
// history exposed to the view (the alternate screen has no scrollback)
int historyVisible() const { return m_altActive ? 0 : int(m_history.size()); }
// index 0 == oldest scrollback line, totalLines() - 1 == last screen line
const Cell *lineData(int index) const;
const QVector<Cell> &lineVector(int index) const;
QString graphemeText(quint16 ref) const;
int cursorRow() const { return m_cy; }
int cursorCol() const { return m_cx; }
bool cursorVisible() const { return m_cursorVisible; }
bool alternateScreen() const { return m_altActive; }
bool bracketedPaste() const { return m_bracketedPaste; }
bool applicationCursorKeys() const { return m_cursorKeysApp; }
bool applicationKeypad() const { return m_keypadApp; }
bool reverseVideo() const { return m_reverseVideo; }
Color defaultForeground() const { return m_defaultFg; }
Color defaultBackground() const { return m_defaultBg; }
void resize(int cols, int rows);
void reset();
void clearScrollback();
void putChar(char32_t cp);
void putCombining(char32_t cp);
void repeatLastChar(int count);
void carriageReturn();
void lineFeed();
void reverseIndex();
void backSpace();
void tab(int count = 1);
void backTab(int count = 1);
void alignTest();
void moveTo(int row, int col);
void cursorUp(int n);
void cursorDown(int n);
void cursorLeft(int n);
void cursorRight(int n);
void setCursorCol(int col);
void setCursorRow(int row);
void eraseInDisplay(int mode);
void eraseInLine(int mode);
void eraseChars(int count);
void insertChars(int count);
void deleteChars(int count);
void insertLines(int count);
void deleteLines(int count);
void scrollUp(int count);
void scrollDown(int count);
void setMargins(int top, int bottom);
void resetMargins();
void saveCursor();
void restoreCursor();
void setSgr(const QVector<int> &params);
void setPrivateMode(int mode, bool on);
void setAnsiMode(int mode, bool on);
void setCharset(int slot, char designator);
void selectCharset(int slot);
void ringBell();
void setTitle(const QString &title);
void setWorkingDirectory(const QString &dir);
void requestClipboard(const QString &text);
void reportDeviceAttributes();
void reportStatus(int mode);
void reportCursorPosition();
signals:
void titleChanged(const QString &title);
void workingDirectoryChanged(const QString &dir);
void response(const QByteArray &data);
void bell();
void clipboardRequested(const QString &text);
private:
QVector<QVector<Cell>> &grid();
const QVector<QVector<Cell>> &grid() const;
Cell blankCell() const;
void clearRow(int row);
void clearCellAt(int row, int col);
void sanitizeWide(int row);
void pushHistory(QVector<Cell> &&row);
void scrollUpRegion(int top, int bottom, int count);
void scrollDownRegion(int top, int bottom, int count);
static void resizeRow(QVector<Cell> &row, int cols);
char32_t mapCharsetChar(char32_t cp) const;
quint16 addGrapheme(quint16 previous, char32_t cp);
int m_cols = 80;
int m_rows = 24;
QVector<QVector<Cell>> m_primary;
QVector<QVector<Cell>> m_alt;
bool m_altActive = false;
std::deque<QVector<Cell>> m_history;
int m_historyLimit = 5000;
int m_cx = 0;
int m_cy = 0;
bool m_pendingWrap = false;
int m_top = 0;
int m_bot = 23;
Color m_fg = kColorDefault;
Color m_bg = kColorDefault;
Color m_defaultFg = kColorDefault;
Color m_defaultBg = kColorDefault;
quint16 m_attr = 0;
bool m_autowrap = true;
bool m_origin = false;
bool m_insert = false;
bool m_cursorVisible = true;
bool m_cursorKeysApp = false;
bool m_keypadApp = false;
bool m_reverseVideo = false;
bool m_bracketedPaste = false;
int m_mouseMode = 0;
// saved cursor (DECSC)
int m_savedCx = 0;
int m_savedCy = 0;
Color m_savedFg = kColorDefault;
Color m_savedBg = kColorDefault;
quint16 m_savedAttr = 0;
bool m_savedOrigin = false;
bool m_savedAutowrap = true;
int m_savedCharset[2] = {0, 0};
int m_charset[2] = {0, 0}; // 0 == ASCII, '0' == DEC special graphics, 'A' == UK
int m_activeCharset = 0;
char32_t m_lastChar = U' ';
QVector<QString> m_graphemes;
};
} // namespace vx
+493
View File
@@ -0,0 +1,493 @@
#include "VtParser.h"
#include "Screen.h"
#include <QByteArray>
#include <QString>
#include <QVector>
#include <algorithm>
namespace vx {
namespace {
void parseParams(const QByteArray &buf, char &priv, QVector<int> &out)
{
out.clear();
int start = 0;
if (!buf.isEmpty()) {
const char c = buf.at(0);
if (c == '?' || c == '>' || c == '=' || c == '<' || c == '!') {
priv = c;
start = 1;
}
}
int value = 0;
bool hasValue = false;
for (int i = start; i < buf.size(); ++i) {
const char c = buf.at(i);
if (c >= '0' && c <= '9') {
value = value * 10 + (c - '0');
hasValue = true;
} else if (c == ';' || c == ':') {
out.append(hasValue ? value : 0);
value = 0;
hasValue = false;
}
}
if (hasValue)
out.append(value);
}
} // namespace
VtParser::VtParser(Screen *screen)
: m_screen(screen)
{
}
void VtParser::reset()
{
m_state = Ground;
m_paramBuf.clear();
m_interBuf.clear();
m_strBuf.clear();
m_charsetSlot = -1;
m_stringEsc = false;
m_utf8Cp = 0;
m_utf8Need = 0;
}
void VtParser::feed(const char *data, int len)
{
if (!m_screen)
return;
for (int i = 0; i < len; ++i) {
const uint8_t b = uint8_t(data[i]);
if (b == 0x18 || b == 0x1A) { // CAN / SUB cancel any sequence
m_state = Ground;
m_paramBuf.clear();
m_interBuf.clear();
m_strBuf.clear();
m_utf8Need = 0;
continue;
}
switch (m_state) {
case Ground:
handleGround(b);
break;
case Escape:
handleEscape(b);
break;
case EscapeInter:
handleEscapeInter(b);
break;
case Csi:
handleCsi(b);
break;
case Osc:
handleOsc(b);
break;
case Dcs:
case Ignore:
if (b == 0x1B) {
m_state = Ignore;
m_stringEsc = true;
} else if (m_stringEsc && b == '\\') {
m_state = Ground;
m_stringEsc = false;
} else if (b == 0x07) {
m_state = Ground;
}
break;
}
}
}
void VtParser::emitText(char32_t cp)
{
m_screen->putChar(cp);
}
void VtParser::handleGround(uint8_t b)
{
if (b == 0x1B) {
m_state = Escape;
m_interBuf.clear();
m_paramBuf.clear();
m_charsetSlot = -1;
return;
}
if (b < 0x20 || b == 0x7F) {
handleControl(b);
return;
}
if (m_utf8Need > 0) {
if ((b & 0xC0) == 0x80) {
m_utf8Cp = (m_utf8Cp << 6) | char32_t(b & 0x3F);
if (--m_utf8Need == 0) {
const char32_t cp = m_utf8Cp;
m_utf8Cp = 0;
emitText(cp);
}
return;
}
m_utf8Need = 0;
m_utf8Cp = 0;
emitText(0xFFFD);
}
if (b < 0x80) {
emitText(char32_t(b));
} else if ((b & 0xE0) == 0xC0) {
m_utf8Cp = char32_t(b & 0x1F);
m_utf8Need = 1;
} else if ((b & 0xF0) == 0xE0) {
m_utf8Cp = char32_t(b & 0x0F);
m_utf8Need = 2;
} else if ((b & 0xF8) == 0xF0) {
m_utf8Cp = char32_t(b & 0x07);
m_utf8Need = 3;
} else {
emitText(0xFFFD);
}
}
void VtParser::handleControl(uint8_t b)
{
switch (b) {
case 0x07:
m_screen->ringBell();
break;
case 0x08:
m_screen->backSpace();
break;
case 0x09:
m_screen->tab(1);
break;
case 0x0A:
case 0x0B:
case 0x0C:
m_screen->lineFeed();
break;
case 0x0D:
m_screen->carriageReturn();
break;
case 0x0E:
m_screen->selectCharset(1);
break;
case 0x0F:
m_screen->selectCharset(0);
break;
default:
break;
}
}
void VtParser::handleEscape(uint8_t b)
{
switch (b) {
case '[':
m_state = Csi;
m_paramBuf.clear();
m_interBuf.clear();
return;
case ']':
m_state = Osc;
m_strBuf.clear();
m_stringEsc = false;
return;
case 'P':
m_state = Dcs;
m_stringEsc = false;
return;
case 'X':
case '^':
case '_':
m_state = Ignore;
m_stringEsc = false;
return;
case '(':
m_charsetSlot = 0;
m_state = EscapeInter;
return;
case ')':
m_charsetSlot = 1;
m_state = EscapeInter;
return;
case '*':
case '+':
m_charsetSlot = -2;
m_state = EscapeInter;
return;
case '#':
case '%':
case ' ':
m_interBuf = QByteArray(1, char(b));
m_state = EscapeInter;
return;
case '7':
m_screen->saveCursor();
m_state = Ground;
return;
case '8':
m_screen->restoreCursor();
m_state = Ground;
return;
case 'D':
m_screen->lineFeed();
m_state = Ground;
return;
case 'E':
m_screen->carriageReturn();
m_screen->lineFeed();
m_state = Ground;
return;
case 'M':
m_screen->reverseIndex();
m_state = Ground;
return;
case 'H':
m_state = Ground;
return;
case 'c':
m_screen->reset();
m_state = Ground;
return;
case '=':
m_state = Ground;
return;
case '>':
m_state = Ground;
return;
default:
m_state = Ground;
return;
}
}
void VtParser::handleEscapeInter(uint8_t b)
{
if (m_charsetSlot >= 0) {
m_screen->setCharset(m_charsetSlot, char(b));
m_charsetSlot = -1;
m_state = Ground;
return;
}
if (m_interBuf == "#" && b == '8') {
m_screen->alignTest();
m_state = Ground;
return;
}
m_state = Ground;
}
void VtParser::handleCsi(uint8_t b)
{
if (b >= 0x30 && b <= 0x3F) {
m_paramBuf.append(char(b));
return;
}
if (b >= 0x20 && b <= 0x2F) {
m_interBuf.append(char(b));
return;
}
if (b >= 0x40 && b <= 0x7E) {
dispatchCsi(b);
m_state = Ground;
return;
}
m_state = Ground;
}
void VtParser::dispatchCsi(uint8_t finalByte)
{
char priv = 0;
QVector<int> params;
parseParams(m_paramBuf, priv, params);
const auto P = [&params](int index, int fallback) {
return index < params.size() ? params.at(index) : fallback;
};
switch (finalByte) {
case '@':
m_screen->insertChars(std::max(1, P(0, 1)));
break;
case 'A':
m_screen->cursorUp(std::max(1, P(0, 1)));
break;
case 'B':
case 'e':
m_screen->cursorDown(std::max(1, P(0, 1)));
break;
case 'C':
case 'a':
m_screen->cursorRight(std::max(1, P(0, 1)));
break;
case 'D':
m_screen->cursorLeft(std::max(1, P(0, 1)));
break;
case 'E':
m_screen->cursorDown(std::max(1, P(0, 1)));
m_screen->setCursorCol(0);
break;
case 'F':
m_screen->cursorUp(std::max(1, P(0, 1)));
m_screen->setCursorCol(0);
break;
case 'G':
case '\140':
m_screen->setCursorCol(std::max(1, P(0, 1)) - 1);
break;
case 'H':
case 'f':
m_screen->moveTo(std::max(1, P(0, 1)) - 1, std::max(1, P(1, 1)) - 1);
break;
case 'I':
m_screen->tab(std::max(1, P(0, 1)));
break;
case 'J':
m_screen->eraseInDisplay(P(0, 0));
break;
case 'K':
m_screen->eraseInLine(P(0, 0));
break;
case 'L':
m_screen->insertLines(std::max(1, P(0, 1)));
break;
case 'M':
m_screen->deleteLines(std::max(1, P(0, 1)));
break;
case 'P':
m_screen->deleteChars(std::max(1, P(0, 1)));
break;
case 'S':
m_screen->scrollUp(std::max(1, P(0, 1)));
break;
case 'T':
m_screen->scrollDown(std::max(1, P(0, 1)));
break;
case 'X':
m_screen->eraseChars(std::max(1, P(0, 1)));
break;
case 'Z':
m_screen->backTab(std::max(1, P(0, 1)));
break;
case 'b':
m_screen->repeatLastChar(std::max(1, P(0, 1)));
break;
case 'c':
if (priv == 0)
m_screen->reportDeviceAttributes();
break;
case 'd':
m_screen->setCursorRow(std::max(1, P(0, 1)) - 1);
break;
case 'g':
break;
case 'h':
case 'l': {
const bool on = (finalByte == 'h');
if (params.isEmpty()) {
if (priv == '?')
m_screen->setPrivateMode(0, on);
}
for (int i = 0; i < params.size(); ++i) {
if (priv == '?')
m_screen->setPrivateMode(params.at(i), on);
else
m_screen->setAnsiMode(params.at(i), on);
}
break;
}
case 'm':
if (priv == 0)
m_screen->setSgr(params);
break;
case 'n':
if (priv == '?')
m_screen->reportStatus(P(0, 0));
break;
case 'q':
break;
case 'r':
m_screen->setMargins(std::max(1, P(0, 1)) - 1, std::max(1, P(1, m_screen->rows())) - 1);
break;
case 's':
if (priv == 0)
m_screen->saveCursor();
break;
case 'u':
if (priv == 0)
m_screen->restoreCursor();
break;
case 't':
break;
default:
break;
}
}
void VtParser::handleOsc(uint8_t b)
{
if (m_stringEsc) {
m_stringEsc = false;
if (b == '\\') {
dispatchOsc();
m_state = Ground;
return;
}
m_strBuf.clear();
m_state = Ground;
return;
}
if (b == 0x07) {
dispatchOsc();
m_state = Ground;
return;
}
if (b == 0x1B) {
m_stringEsc = true;
return;
}
if (m_strBuf.size() < 8192)
m_strBuf.append(char(b));
}
void VtParser::dispatchOsc()
{
const QByteArray payload = m_strBuf;
m_strBuf.clear();
const int sep = payload.indexOf(';');
const int code = sep < 0 ? 0 : payload.left(sep).toInt();
const QByteArray rest = sep < 0 ? QByteArray() : payload.mid(sep + 1);
switch (code) {
case 0:
case 2:
m_screen->setTitle(QString::fromUtf8(rest));
break;
case 7: {
QByteArray url = rest;
if (url.startsWith("file://"))
url = url.mid(7);
m_screen->setWorkingDirectory(QString::fromUtf8(url));
break;
}
case 52: {
const int semi = rest.indexOf(';');
if (semi >= 0) {
const QByteArray data = rest.mid(semi + 1);
m_screen->requestClipboard(QString::fromUtf8(QByteArray::fromBase64(data)));
}
break;
}
default:
break;
}
}
} // namespace vx
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <QByteArray>
#include <cstdint>
namespace vx {
class Screen;
// Incremental VT100/xterm escape sequence parser. Bytes are decoded from UTF-8
// and pushed into Screen; no rendering happens here.
class VtParser
{
public:
explicit VtParser(Screen *screen);
void setScreen(Screen *screen) { m_screen = screen; }
Screen *screen() const { return m_screen; }
void feed(const char *data, int len);
void feed(const QByteArray &data) { feed(data.constData(), data.size()); }
void reset();
private:
enum State {
Ground,
Escape,
EscapeInter,
Csi,
Osc,
Dcs,
Ignore,
};
void handleGround(uint8_t b);
void handleEscape(uint8_t b);
void handleEscapeInter(uint8_t b);
void handleCsi(uint8_t b);
void handleOsc(uint8_t b);
void handleControl(uint8_t b);
void dispatchCsi(uint8_t finalByte);
void dispatchOsc();
void emitText(char32_t cp);
Screen *m_screen = nullptr;
State m_state = Ground;
QByteArray m_paramBuf;
QByteArray m_interBuf;
QByteArray m_strBuf;
int m_charsetSlot = -1;
bool m_stringEsc = false;
char32_t m_utf8Cp = 0;
int m_utf8Need = 0;
};
} // namespace vx
+88
View File
@@ -0,0 +1,88 @@
#include "session/SessionSpec.h"
#include "ui/MainWindow.h"
#include "ui/Style.h"
#include <QApplication>
#include <QCommandLineParser>
namespace {
// Accepts "user@host:port", "telnet://host:port" or "serial:/dev/ttyUSB0@115200".
bool parseTarget(const QString &target, vx::SessionSpec &spec)
{
if (target.startsWith(QStringLiteral("telnet://"))) {
QString rest = target.mid(9);
const int colon = rest.lastIndexOf(QLatin1Char(':'));
spec.type = vx::SessionSpec::Telnet;
spec.port = 23;
if (colon > 0) {
spec.port = rest.mid(colon + 1).toInt();
rest = rest.left(colon);
}
spec.host = rest;
} else if (target.startsWith(QStringLiteral("serial:"))) {
QString rest = target.mid(7);
const int at = rest.lastIndexOf(QLatin1Char('@'));
spec.type = vx::SessionSpec::Serial;
if (at > 0) {
spec.baudRate = rest.mid(at + 1).toInt();
rest = rest.left(at);
}
spec.serialPort = rest;
} else {
QString rest = target;
spec.type = vx::SessionSpec::Ssh;
spec.port = 22;
const int at = rest.indexOf(QLatin1Char('@'));
if (at > 0) {
spec.user = rest.left(at);
rest = rest.mid(at + 1);
}
// An explicit ":port" only counts when the host is not IPv6.
const int colon = rest.lastIndexOf(QLatin1Char(':'));
if (colon > 0 && rest.count(QLatin1Char(':')) == 1) {
spec.port = rest.mid(colon + 1).toInt();
rest = rest.left(colon);
}
spec.host = rest;
}
if (spec.host.isEmpty() && spec.serialPort.isEmpty())
return false;
spec.name = spec.displayName();
return true;
}
} // namespace
int main(int argc, char *argv[])
{
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
QCoreApplication::setAttribute(Qt::AA_UseHighDpiPixmaps);
#endif
QApplication app(argc, argv);
QCoreApplication::setOrganizationName(QStringLiteral("VoiletXterm"));
QCoreApplication::setApplicationName(QStringLiteral("VoiletXterm"));
QCoreApplication::setApplicationVersion(QStringLiteral("0.1.0"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("VoiletXterm - SSH / Telnet / serial terminal"));
parser.addHelpOption();
parser.addVersionOption();
parser.process(app);
vx::applyDarkStyle(app);
vx::MainWindow window;
window.show();
const QStringList targets = parser.positionalArguments();
if (!targets.isEmpty()) {
vx::SessionSpec spec;
if (parseTarget(targets.first(), spec))
window.openSpec(spec);
}
return app.exec();
}
+224
View File
@@ -0,0 +1,224 @@
#include "PtyProcess.h"
#include <QSocketNotifier>
#ifndef Q_OS_UNIX
#include <QDebug>
#endif
#ifdef Q_OS_UNIX
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <pty.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
#endif
namespace vx {
PtyProcess::PtyProcess(QObject *parent)
: QObject(parent)
{
}
PtyProcess::~PtyProcess()
{
stop();
}
bool PtyProcess::start(const QString &program, const QStringList &args,
const QHash<QString, QString> &environment, int cols, int rows)
{
#ifdef Q_OS_UNIX
if (m_fd >= 0)
return false;
struct winsize ws;
std::memset(&ws, 0, sizeof(ws));
ws.ws_col = ushort(cols);
ws.ws_row = ushort(rows);
int master = -1;
const pid_t pid = forkpty(&master, nullptr, nullptr, &ws);
if (pid < 0)
return false;
if (pid == 0) {
setenv("TERM", "xterm-256color", 1);
setenv("COLORTERM", "truecolor", 1);
setenv("TERM_PROGRAM", "VoiletXterm", 1);
for (auto it = environment.constBegin(); it != environment.constEnd(); ++it)
setenv(it.key().toLocal8Bit().constData(), it.value().toLocal8Bit().constData(), 1);
std::vector<char *> argv;
QByteArray programBytes = program.toLocal8Bit();
argv.push_back(programBytes.data());
for (const QString &arg : args)
argv.push_back(strdup(arg.toLocal8Bit().constData()));
argv.push_back(nullptr);
execvp(programBytes.constData(), argv.data());
_exit(127);
}
m_fd = master;
m_pid = pid;
fcntl(m_fd, F_SETFL, fcntl(m_fd, F_GETFL) | O_NONBLOCK);
m_readNotifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this);
connect(m_readNotifier, &QSocketNotifier::activated, this, &PtyProcess::onReadable);
return true;
#else
Q_UNUSED(program)
Q_UNUSED(args)
Q_UNUSED(environment)
Q_UNUSED(cols)
Q_UNUSED(rows)
return false;
#endif
}
void PtyProcess::onReadable()
{
#ifdef Q_OS_UNIX
char buffer[65536];
for (;;) {
const ssize_t n = ::read(m_fd, buffer, sizeof(buffer));
if (n > 0) {
emit dataRead(QByteArray(buffer, int(n)));
} else if (n == 0) {
finish(0, QStringLiteral("会话已结束"));
return;
} else {
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK)
break;
finish(-1, QStringLiteral("连接已断开"));
return;
}
}
#endif
}
void PtyProcess::write(const QByteArray &data)
{
if (m_fd < 0 || data.isEmpty())
return;
m_outBuffer.append(data);
flushOutput();
}
void PtyProcess::flushOutput()
{
#ifdef Q_OS_UNIX
while (!m_outBuffer.isEmpty() && m_fd >= 0) {
const ssize_t n = ::write(m_fd, m_outBuffer.constData(), size_t(m_outBuffer.size()));
if (n > 0) {
m_outBuffer.remove(0, int(n));
} else if (n < 0 && errno == EINTR) {
continue;
} else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
if (!m_writeNotifier) {
m_writeNotifier = new QSocketNotifier(m_fd, QSocketNotifier::Write, this);
connect(m_writeNotifier, &QSocketNotifier::activated, this, &PtyProcess::onWritable);
}
return;
} else {
break;
}
}
if (m_writeNotifier && m_outBuffer.isEmpty()) {
m_writeNotifier->setEnabled(false);
}
#endif
}
void PtyProcess::onWritable()
{
flushOutput();
}
void PtyProcess::setWindowSize(int cols, int rows)
{
#ifdef Q_OS_UNIX
if (m_fd < 0)
return;
struct winsize ws;
std::memset(&ws, 0, sizeof(ws));
ws.ws_col = ushort(cols);
ws.ws_row = ushort(rows);
ioctl(m_fd, TIOCSWINSZ, &ws);
#else
Q_UNUSED(cols)
Q_UNUSED(rows)
#endif
}
void PtyProcess::closeFd()
{
if (m_readNotifier) {
m_readNotifier->setEnabled(false);
delete m_readNotifier;
m_readNotifier = nullptr;
}
if (m_writeNotifier) {
m_writeNotifier->setEnabled(false);
delete m_writeNotifier;
m_writeNotifier = nullptr;
}
if (m_fd >= 0) {
::close(m_fd);
m_fd = -1;
}
}
void PtyProcess::finish(int exitCode, const QString &reason)
{
if (m_finished)
return;
m_finished = true;
closeFd();
#ifdef Q_OS_UNIX
if (m_pid > 0) {
int status = 0;
for (int i = 0; i < 20; ++i) {
const pid_t r = waitpid(pid_t(m_pid), &status, WNOHANG);
if (r == pid_t(m_pid) || (r < 0 && errno == ECHILD))
break;
usleep(10000);
}
if (WIFEXITED(status) && exitCode < 0)
exitCode = WEXITSTATUS(status);
}
#endif
emit closed(exitCode, reason);
}
void PtyProcess::stop()
{
if (m_fd < 0 && m_pid <= 0)
return;
closeFd();
#ifdef Q_OS_UNIX
if (m_pid > 0) {
::kill(pid_t(m_pid), SIGHUP);
int status = 0;
const pid_t r = waitpid(pid_t(m_pid), &status, 0);
if (r < 0) {
::kill(pid_t(m_pid), SIGKILL);
waitpid(pid_t(m_pid), &status, 0);
}
}
#endif
m_pid = -1;
m_finished = true;
}
} // namespace vx
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <QByteArray>
#include <QHash>
#include <QObject>
#include <QStringList>
class QSocketNotifier;
namespace vx {
// Runs a child process attached to a pseudo terminal, so window size changes
// are propagated and interactive programs (ssh, login shells) behave normally.
class PtyProcess : public QObject
{
Q_OBJECT
public:
explicit PtyProcess(QObject *parent = nullptr);
~PtyProcess() override;
bool start(const QString &program, const QStringList &args,
const QHash<QString, QString> &environment, int cols, int rows);
void write(const QByteArray &data);
void setWindowSize(int cols, int rows);
void stop();
bool isRunning() const { return m_fd >= 0; }
qint64 processId() const { return m_pid; }
signals:
void dataRead(const QByteArray &data);
void closed(int exitCode, const QString &reason);
private slots:
void onReadable();
void onWritable();
private:
void flushOutput();
void finish(int exitCode, const QString &reason);
void closeFd();
int m_fd = -1;
qint64 m_pid = -1;
QSocketNotifier *m_readNotifier = nullptr;
QSocketNotifier *m_writeNotifier = nullptr;
QByteArray m_outBuffer;
bool m_finished = false;
};
} // namespace vx
+98
View File
@@ -0,0 +1,98 @@
#include "SerialTransport.h"
#include <QSerialPortInfo>
namespace vx {
SerialTransport::SerialTransport(const SessionSpec &spec, QObject *parent)
: Transport(parent)
, m_spec(spec)
{
m_description = spec.summary();
}
QStringList SerialTransport::availablePorts()
{
QStringList ports;
const auto infos = QSerialPortInfo::availablePorts();
for (const QSerialPortInfo &info : infos) {
QString label = info.portName();
if (!info.description().isEmpty())
label += QStringLiteral(" (%1)").arg(info.description());
ports << label;
}
return ports;
}
bool SerialTransport::start(int cols, int rows)
{
Q_UNUSED(cols)
Q_UNUSED(rows)
if (m_spec.serialPort.trimmed().isEmpty()) {
emitFinished(tr("未选择串口"));
return false;
}
m_port = new QSerialPort(this);
m_port->setPortName(m_spec.serialPort);
m_port->setBaudRate(m_spec.baudRate);
switch (m_spec.dataBits) {
case 5: m_port->setDataBits(QSerialPort::Data5); break;
case 6: m_port->setDataBits(QSerialPort::Data6); break;
case 7: m_port->setDataBits(QSerialPort::Data7); break;
default: m_port->setDataBits(QSerialPort::Data8); break;
}
if (m_spec.parity == QLatin1String("even"))
m_port->setParity(QSerialPort::EvenParity);
else if (m_spec.parity == QLatin1String("odd"))
m_port->setParity(QSerialPort::OddParity);
else if (m_spec.parity == QLatin1String("space"))
m_port->setParity(QSerialPort::SpaceParity);
else if (m_spec.parity == QLatin1String("mark"))
m_port->setParity(QSerialPort::MarkParity);
else
m_port->setParity(QSerialPort::NoParity);
if (m_spec.stopBits == 2)
m_port->setStopBits(QSerialPort::TwoStop);
else
m_port->setStopBits(QSerialPort::OneStop);
if (m_spec.flowControl == QLatin1String("hardware"))
m_port->setFlowControl(QSerialPort::HardwareControl);
else if (m_spec.flowControl == QLatin1String("software"))
m_port->setFlowControl(QSerialPort::SoftwareControl);
else
m_port->setFlowControl(QSerialPort::NoFlowControl);
connect(m_port, &QSerialPort::readyRead, this, [this] {
emitData(m_port->readAll());
});
connect(m_port, &QSerialPort::errorOccurred, this, [this](QSerialPort::SerialPortError error) {
if (error == QSerialPort::NoError || error == QSerialPort::TimeoutError)
return;
emitFinished(m_port->errorString());
});
if (!m_port->open(QIODevice::ReadWrite)) {
emitFinished(tr("无法打开串口 %1: %2").arg(m_spec.serialPort, m_port->errorString()));
return false;
}
emitConnected();
return true;
}
void SerialTransport::write(const QByteArray &data)
{
if (!m_port || !m_port->isOpen())
return;
m_port->write(data);
if (m_spec.localEcho)
emitData(data);
}
} // namespace vx
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "SessionSpec.h"
#include "Transport.h"
#include <QSerialPort>
namespace vx {
class SerialTransport : public Transport
{
Q_OBJECT
public:
explicit SerialTransport(const SessionSpec &spec, QObject *parent = nullptr);
bool start(int cols, int rows) override;
void write(const QByteArray &data) override;
QString description() const override { return m_description; }
static QStringList availablePorts();
private:
SessionSpec m_spec;
QString m_description;
QSerialPort *m_port = nullptr;
};
} // namespace vx
+23
View File
@@ -0,0 +1,23 @@
#include "SessionFactory.h"
#include "SerialTransport.h"
#include "SshTransport.h"
#include "TelnetTransport.h"
#include "Transport.h"
namespace vx {
Transport *createTransport(const SessionSpec &spec, QObject *parent)
{
switch (spec.type) {
case SessionSpec::Ssh:
return new SshTransport(spec, parent);
case SessionSpec::Telnet:
return new TelnetTransport(spec, parent);
case SessionSpec::Serial:
return new SerialTransport(spec, parent);
}
return nullptr;
}
} // namespace vx
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include "SessionSpec.h"
namespace vx {
class Transport;
Transport *createTransport(const SessionSpec &spec, QObject *parent);
} // namespace vx
+80
View File
@@ -0,0 +1,80 @@
#include "SessionSpec.h"
#include <QJsonValue>
namespace vx {
QString SessionSpec::displayName() const
{
if (!name.isEmpty())
return name;
if (type == Serial)
return serialPort.isEmpty() ? QStringLiteral("串口") : serialPort;
const QString hostPart = user.isEmpty() ? host : QStringLiteral("%1@%2").arg(user, host);
if (type == Telnet)
return QStringLiteral("telnet://%1:%2").arg(host).arg(port);
return hostPart.isEmpty() ? QStringLiteral("SSH") : hostPart;
}
QString SessionSpec::summary() const
{
switch (type) {
case Ssh:
return QStringLiteral("SSH %1:%2").arg(host).arg(port);
case Telnet:
return QStringLiteral("Telnet %1:%2").arg(host).arg(port);
case Serial: {
QString p = serialPort.isEmpty() ? QStringLiteral("?") : serialPort;
return QStringLiteral("串口 %1 @ %2").arg(p).arg(baudRate);
}
}
return QString();
}
QJsonObject SessionSpec::toJson() const
{
QJsonObject o;
o.insert(QStringLiteral("type"), int(type));
o.insert(QStringLiteral("name"), name);
o.insert(QStringLiteral("host"), host);
o.insert(QStringLiteral("port"), port);
o.insert(QStringLiteral("user"), user);
o.insert(QStringLiteral("keyFile"), keyFile);
o.insert(QStringLiteral("jumpHost"), jumpHost);
o.insert(QStringLiteral("remoteCommand"), remoteCommand);
o.insert(QStringLiteral("extraOptions"), extraOptions.join(QLatin1Char(' ')));
o.insert(QStringLiteral("serialPort"), serialPort);
o.insert(QStringLiteral("baudRate"), baudRate);
o.insert(QStringLiteral("dataBits"), dataBits);
o.insert(QStringLiteral("stopBits"), stopBits);
o.insert(QStringLiteral("parity"), parity);
o.insert(QStringLiteral("flowControl"), flowControl);
o.insert(QStringLiteral("localEcho"), localEcho);
return o;
}
SessionSpec SessionSpec::fromJson(const QJsonObject &o)
{
SessionSpec s;
s.type = Type(qBound(0, o.value(QStringLiteral("type")).toInt(), 2));
s.name = o.value(QStringLiteral("name")).toString();
s.host = o.value(QStringLiteral("host")).toString();
s.port = o.value(QStringLiteral("port")).toInt(s.type == Telnet ? 23 : 22);
s.user = o.value(QStringLiteral("user")).toString();
s.keyFile = o.value(QStringLiteral("keyFile")).toString();
s.jumpHost = o.value(QStringLiteral("jumpHost")).toString();
s.remoteCommand = o.value(QStringLiteral("remoteCommand")).toString();
const QString extras = o.value(QStringLiteral("extraOptions")).toString();
if (!extras.isEmpty())
s.extraOptions = extras.split(QLatin1Char(' '), Qt::SkipEmptyParts);
s.serialPort = o.value(QStringLiteral("serialPort")).toString();
s.baudRate = o.value(QStringLiteral("baudRate")).toInt(115200);
s.dataBits = o.value(QStringLiteral("dataBits")).toInt(8);
s.stopBits = o.value(QStringLiteral("stopBits")).toInt(1);
s.parity = o.value(QStringLiteral("parity")).toString(QStringLiteral("none"));
s.flowControl = o.value(QStringLiteral("flowControl")).toString(QStringLiteral("none"));
s.localEcho = o.value(QStringLiteral("localEcho")).toBool(false);
return s;
}
} // namespace vx
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <QStringList>
namespace vx {
struct SessionSpec {
enum Type { Ssh = 0, Telnet = 1, Serial = 2 };
Type type = Ssh;
QString name;
// SSH / Telnet
QString host;
int port = 22;
QString user;
QString keyFile;
QString jumpHost;
QString remoteCommand;
QStringList extraOptions;
// serial
QString serialPort;
int baudRate = 115200;
int dataBits = 8;
int stopBits = 1;
QString parity = QStringLiteral("none");
QString flowControl = QStringLiteral("none");
bool localEcho = false;
QString displayName() const;
QString summary() const;
QJsonObject toJson() const;
static SessionSpec fromJson(const QJsonObject &object);
};
} // namespace vx
+97
View File
@@ -0,0 +1,97 @@
#include "SshTransport.h"
#include "PtyProcess.h"
#include <QDir>
#include <QFileInfo>
namespace vx {
SshTransport::SshTransport(const SessionSpec &spec, QObject *parent)
: Transport(parent)
, m_spec(spec)
{
m_description = spec.summary();
}
SshTransport::~SshTransport() = default;
void SshTransport::buildCommand(QString &program, QStringList &args, QHash<QString, QString> &env) const
{
program = QStringLiteral("ssh");
args.clear();
const int port = m_spec.port > 0 ? m_spec.port : 22;
args << QStringLiteral("-p") << QString::number(port);
if (!m_spec.keyFile.isEmpty()) {
QString key = m_spec.keyFile;
if (key.startsWith(QLatin1Char('~')))
key = QDir::homePath() + key.mid(1);
args << QStringLiteral("-i") << key;
}
if (!m_spec.jumpHost.isEmpty()) {
args << QStringLiteral("-J") << m_spec.jumpHost;
}
args << QStringLiteral("-o") << QStringLiteral("ServerAliveInterval=30")
<< QStringLiteral("-o") << QStringLiteral("ServerAliveCountMax=3")
<< QStringLiteral("-o") << QStringLiteral("TCPKeepAlive=yes")
<< QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=3")
<< QStringLiteral("-o") << QStringLiteral("EscapeChar=~");
for (const QString &option : m_spec.extraOptions)
args << QStringLiteral("-o") << option;
const QString target = m_spec.user.isEmpty()
? m_spec.host
: QStringLiteral("%1@%2").arg(m_spec.user, m_spec.host);
args << target;
if (!m_spec.remoteCommand.trimmed().isEmpty()) {
args << QStringLiteral("-t") << m_spec.remoteCommand;
}
env.insert(QStringLiteral("LANG"), QStringLiteral("en_US.UTF-8"));
}
bool SshTransport::start(int cols, int rows)
{
if (m_spec.host.trimmed().isEmpty()) {
emitFinished(tr("未填写主机地址"));
return false;
}
QString program;
QStringList args;
QHash<QString, QString> env;
buildCommand(program, args, env);
m_pty = new PtyProcess(this);
connect(m_pty, &PtyProcess::dataRead, this, &Transport::emitData);
connect(m_pty, &PtyProcess::closed, this, [this](int, const QString &reason) {
emitFinished(reason);
});
if (!m_pty->start(program, args, env, cols, rows)) {
emitFinished(tr("无法启动 ssh 进程"));
return false;
}
emitConnected();
return true;
}
void SshTransport::write(const QByteArray &data)
{
if (m_pty)
m_pty->write(data);
}
void SshTransport::resizeTerminal(int cols, int rows)
{
if (m_pty)
m_pty->setWindowSize(cols, rows);
}
} // namespace vx
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "SessionSpec.h"
#include "Transport.h"
namespace vx {
class PtyProcess;
// SSH is delegated to the system OpenSSH client running on a pseudo terminal.
// This keeps host key handling, agent, ProxyJump and keyboard-interactive auth
// exactly as users expect, without reimplementing the protocol in-tree.
class SshTransport : public Transport
{
Q_OBJECT
public:
explicit SshTransport(const SessionSpec &spec, QObject *parent = nullptr);
~SshTransport() override;
bool start(int cols, int rows) override;
void write(const QByteArray &data) override;
void resizeTerminal(int cols, int rows) override;
QString description() const override { return m_description; }
private:
void buildCommand(QString &program, QStringList &args, QHash<QString, QString> &env) const;
SessionSpec m_spec;
QString m_description;
PtyProcess *m_pty = nullptr;
};
} // namespace vx
+197
View File
@@ -0,0 +1,197 @@
#include "TelnetTransport.h"
#include <QTcpSocket>
namespace vx {
namespace {
constexpr char IAC = char(0xFF);
constexpr char DONT = char(0xFE);
constexpr char DO = char(0xFD);
constexpr char WONT = char(0xFC);
constexpr char WILL = char(0xFB);
constexpr char SB = char(0xFA);
constexpr char SE = char(0xF0);
} // namespace
TelnetTransport::TelnetTransport(const SessionSpec &spec, QObject *parent)
: Transport(parent)
, m_spec(spec)
{
m_description = spec.summary();
}
bool TelnetTransport::start(int cols, int rows)
{
if (m_spec.host.trimmed().isEmpty()) {
emitFinished(tr("未填写主机地址"));
return false;
}
m_columns = cols;
m_rows = rows;
m_socket = new QTcpSocket(this);
connect(m_socket, &QTcpSocket::connected, this, [this] { emitConnected(); });
connect(m_socket, &QTcpSocket::readyRead, this, &TelnetTransport::onReadyRead);
connect(m_socket, &QTcpSocket::disconnected, this, [this] {
emitFinished(tr("连接已关闭"));
});
connect(m_socket, &QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) {
emitFinished(m_socket->errorString());
});
const int port = m_spec.port > 0 ? m_spec.port : 23;
m_socket->connectToHost(m_spec.host, quint16(port));
return true;
}
void TelnetTransport::sendCommand(char command, char option)
{
if (!m_socket || m_socket->state() != QAbstractSocket::ConnectedState)
return;
QByteArray data;
data.append(IAC);
data.append(command);
data.append(option);
m_socket->write(data);
}
void TelnetTransport::sendWindowSize()
{
if (!m_socket || m_socket->state() != QAbstractSocket::ConnectedState)
return;
QByteArray data;
data.append(IAC);
data.append(SB);
data.append(char(OptNaws));
data.append(char((m_columns >> 8) & 0xFF));
data.append(char(m_columns & 0xFF));
data.append(char((m_rows >> 8) & 0xFF));
data.append(char(m_rows & 0xFF));
data.append(IAC);
data.append(SE);
m_socket->write(data);
}
void TelnetTransport::onReadyRead()
{
const QByteArray chunk = m_socket->readAll();
QByteArray out;
out.reserve(chunk.size());
for (char raw : chunk) {
const unsigned char b = static_cast<unsigned char>(raw);
switch (m_state) {
case Data:
if (b == 0xFF)
m_state = Iac;
else if (b == 0x00)
; // NUL after CR is padding
else
out.append(raw);
break;
case Iac:
switch (b) {
case 0xFF:
out.append(char(0xFF));
m_state = Data;
break;
case 0xFB:
m_state = Will;
break;
case 0xFC:
m_state = Wont;
break;
case 0xFD:
m_state = Do;
break;
case 0xFE:
m_state = Dont;
break;
case 0xFA:
m_state = Sub;
break;
default:
m_state = Data; // NOP, GA, AYT, DM, BRK ...
break;
}
break;
case Will: {
const int option = b;
if (m_negotiated.contains(option)) {
m_state = Data;
break;
}
m_negotiated.insert(option);
if (option == OptEcho || option == OptSga) {
sendCommand(DO, char(option));
} else if (option == OptNaws) {
sendCommand(DO, char(option));
m_naws = true;
sendWindowSize();
} else {
sendCommand(DONT, char(option));
}
m_state = Data;
break;
}
case Do: {
const int option = b;
if (m_negotiated.contains(option)) {
m_state = Data;
break;
}
m_negotiated.insert(option);
if (option == OptNaws) {
sendCommand(WILL, char(option));
m_naws = true;
sendWindowSize();
} else if (option == OptSga || option == OptTtype) {
sendCommand(WILL, char(option));
} else {
sendCommand(WONT, char(option));
}
m_state = Data;
break;
}
case Wont:
case Dont:
m_state = Data;
break;
case Sub:
if (b == 0xFF)
m_state = SubIac;
break;
case SubIac:
m_state = (b == 0xF0) ? Data : Sub;
break;
}
}
if (!out.isEmpty())
emitData(out);
}
void TelnetTransport::write(const QByteArray &data)
{
if (!m_socket || m_socket->state() != QAbstractSocket::ConnectedState)
return;
QByteArray escaped;
escaped.reserve(data.size());
for (char c : data) {
escaped.append(c);
if (static_cast<unsigned char>(c) == 0xFF)
escaped.append(IAC);
}
m_socket->write(escaped);
}
void TelnetTransport::resizeTerminal(int cols, int rows)
{
m_columns = cols;
m_rows = rows;
if (m_naws && m_socket && m_socket->state() == QAbstractSocket::ConnectedState)
sendWindowSize();
}
} // namespace vx
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "SessionSpec.h"
#include "Transport.h"
#include <QSet>
class QTcpSocket;
namespace vx {
class TelnetTransport : public Transport
{
Q_OBJECT
public:
explicit TelnetTransport(const SessionSpec &spec, QObject *parent = nullptr);
bool start(int cols, int rows) override;
void write(const QByteArray &data) override;
void resizeTerminal(int cols, int rows) override;
QString description() const override { return m_description; }
private:
enum State { Data, Iac, Will, Wont, Do, Dont, Sub, SubIac };
enum Option { OptEcho = 1, OptSga = 3, OptTtype = 24, OptNaws = 31 };
void onReadyRead();
void sendCommand(char command, char option);
void sendWindowSize();
SessionSpec m_spec;
QString m_description;
QTcpSocket *m_socket = nullptr;
State m_state = Data;
int m_columns = 80;
int m_rows = 24;
bool m_naws = false;
QSet<int> m_negotiated;
QByteArray m_pendingOut;
};
} // namespace vx
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <QByteArray>
#include <QObject>
#include <QString>
namespace vx {
// Abstract byte pipe between a terminal widget and a remote device.
class Transport : public QObject
{
Q_OBJECT
public:
explicit Transport(QObject *parent = nullptr) : QObject(parent) {}
~Transport() override = default;
virtual bool start(int cols, int rows) = 0;
virtual void write(const QByteArray &data) = 0;
virtual void resizeTerminal(int cols, int rows) { Q_UNUSED(cols) Q_UNUSED(rows) }
virtual QString description() const = 0;
virtual bool isConnected() const { return m_connected; }
void emitConnected() { m_connected = true; emit connected(); }
void emitData(const QByteArray &data) { emit dataReceived(data); }
void emitFinished(const QString &reason)
{
m_connected = false;
emit finished(reason);
}
signals:
void dataReceived(const QByteArray &data);
void connected();
void finished(const QString &reason);
private:
bool m_connected = false;
};
} // namespace vx
+34
View File
@@ -0,0 +1,34 @@
#include "TerminalTheme.h"
namespace vx {
TerminalTheme darkVioletTheme()
{
TerminalTheme t;
t.background = QColor(0x14, 0x12, 0x1C);
t.foreground = QColor(0xE4, 0xE0, 0xF0);
t.cursor = QColor(0xA7, 0x8B, 0xFA);
t.cursorText = QColor(0x14, 0x12, 0x1C);
t.selection = QColor(0x4A, 0x3F, 0x7A);
t.ansi[0] = QColor(0x1E, 0x1B, 0x28);
t.ansi[1] = QColor(0xE0, 0x6C, 0x75);
t.ansi[2] = QColor(0x98, 0xC3, 0x79);
t.ansi[3] = QColor(0xE5, 0xC0, 0x7B);
t.ansi[4] = QColor(0x61, 0xAF, 0xEF);
t.ansi[5] = QColor(0xC6, 0x78, 0xDD);
t.ansi[6] = QColor(0x56, 0xB6, 0xC2);
t.ansi[7] = QColor(0xCD, 0xD3, 0xE0);
t.ansi[8] = QColor(0x4B, 0x46, 0x63);
t.ansi[9] = QColor(0xFF, 0x7B, 0x86);
t.ansi[10] = QColor(0xA9, 0xD4, 0x85);
t.ansi[11] = QColor(0xFF, 0xD6, 0x8A);
t.ansi[12] = QColor(0x7C, 0xC4, 0xFF);
t.ansi[13] = QColor(0xD7, 0x9B, 0xF0);
t.ansi[14] = QColor(0x6F, 0xD3, 0xDE);
t.ansi[15] = QColor(0xFF, 0xFF, 0xFF);
return t;
}
} // namespace vx
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <QColor>
namespace vx {
struct TerminalTheme {
QColor background;
QColor foreground;
QColor cursor;
QColor cursorText;
QColor selection;
QColor ansi[16];
};
TerminalTheme darkVioletTheme();
} // namespace vx
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include <QAbstractScrollArea>
#include <QColor>
#include <QElapsedTimer>
#include <QFont>
#include <QPoint>
#include <QTimer>
#include <QVector>
#include "../core/Screen.h"
#include "../core/VtParser.h"
#include "TerminalTheme.h"
class QScrollBar;
namespace vx {
class Transport;
class TerminalWidget : public QAbstractScrollArea
{
Q_OBJECT
public:
explicit TerminalWidget(QWidget *parent = nullptr);
~TerminalWidget() override;
Screen *screen() const { return m_screen; }
VtParser *parser() const { return m_parser; }
void setTransport(Transport *transport);
Transport *transport() const { return m_transport; }
void releaseTransport();
void setTheme(const TerminalTheme &theme);
const TerminalTheme &theme() const { return m_theme; }
void setBaseFontFamily(const QString &family);
QString baseFontFamily() const { return m_fontFamily; }
void setBaseFontSize(int points);
int baseFontSize() const { return m_fontSize; }
QSize sizeHint() const override;
signals:
void titleChanged(const QString &title);
void workingDirectoryChanged(const QString &dir);
void bell();
void sessionFinished(const QString &reason);
void userActivity();
public slots:
void copySelection();
void pasteClipboard();
void selectAll();
void clearScrollback();
void clearScreen();
void zoomIn();
void zoomOut();
void resetZoom();
protected:
void paintEvent(QPaintEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void inputMethodEvent(QInputMethodEvent *event) override;
QVariant inputMethodQuery(Qt::InputMethodQuery query) const override;
void focusInEvent(QFocusEvent *event) override;
void focusOutEvent(QFocusEvent *event) override;
void contextMenuEvent(QContextMenuEvent *event) override;
void showEvent(QShowEvent *event) override;
private slots:
void onDataReceived(const QByteArray &data);
void onTransportFinished(const QString &reason);
void onBlinkTimer();
void onScrollBarMoved(int value);
private:
int visibleRows() const;
int visibleColumns() const;
int topLineIndex() const;
int historyVisible() const;
void recomputeGeometry();
void updateScrollBar();
void updateFonts();
QString cellText(const Cell &cell) const;
QColor colorFor(Color color, bool foreground) const;
const QFont &fontFor(quint16 attr) const;
void drawRow(QPainter &painter, int screenRow, int absoluteLine);
void drawCursor(QPainter &painter);
void drawPreedit(QPainter &painter);
bool hasSelection() const;
bool isSelected(int absoluteLine, int column) const;
void clearSelection();
void selectionRange(int &startLine, int &startCol, int &endLine, int &endCol) const;
QString selectedText() const;
void copyToClipboards(const QString &text);
QPoint cellAt(const QPoint &pos) const;
int absoluteLineAt(int y) const;
void startSelection(const QPoint &pos, int mode);
void extendSelection(const QPoint &pos);
void setSelection(int startLine, int startCol, int endLine, int endCol);
void selectWordAt(const QPoint &pos);
void selectLineAt(const QPoint &pos);
void sendText(const QString &text);
void sendBytes(const QByteArray &bytes);
void sendKey(QKeyEvent *event);
void scrollTo(int offset);
Screen *m_screen = nullptr;
VtParser *m_parser = nullptr;
Transport *m_transport = nullptr;
TerminalTheme m_theme;
QColor m_palette[256];
QString m_fontFamily;
int m_fontSize = 11;
QFont m_fonts[4]; // normal, bold, italic, bold+italic
qreal m_cellWidth = 8;
qreal m_cellHeight = 16;
qreal m_ascent = 12;
QTimer m_blinkTimer;
bool m_cursorOn = true;
int m_scrollOffset = 0;
bool m_updatingScrollBar = false;
// selection (absolute line indices, inclusive)
bool m_selecting = false;
bool m_hasSelection = false;
int m_selStartLine = 0;
int m_selStartCol = 0;
int m_selEndLine = 0;
int m_selEndCol = 0;
int m_selectMode = 0; // 0 char, 1 word, 2 line
QElapsedTimer m_clickTimer;
int m_clickCount = 0;
QString m_preedit;
bool m_showCursor = true;
};
} // namespace vx
+350
View File
@@ -0,0 +1,350 @@
#include "MainWindow.h"
#include "SessionDialog.h"
#include "SessionStore.h"
#include "../session/SessionFactory.h"
#include "../session/Transport.h"
#include "../term/TerminalWidget.h"
#include <QAction>
#include <QApplication>
#include <QCloseEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QMenuBar>
#include <QMessageBox>
#include <QPushButton>
#include <QSettings>
#include <QStackedWidget>
#include <QStatusBar>
#include <QTabWidget>
#include <QToolBar>
#include <QVBoxLayout>
namespace vx {
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
{
setWindowTitle(QStringLiteral("VoiletXterm"));
resize(1100, 700);
buildUi();
buildActions();
QSettings settings;
const QByteArray geometry = settings.value(QStringLiteral("window/geometry")).toByteArray();
if (!geometry.isEmpty())
restoreGeometry(geometry);
}
MainWindow::~MainWindow() = default;
QWidget *MainWindow::buildWelcomePage()
{
auto *page = new QWidget(this);
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(60, 60, 60, 60);
layout->setSpacing(14);
auto *title = new QLabel(QStringLiteral("VoiletXterm"), page);
QFont titleFont = title->font();
titleFont.setPointSize(titleFont.pointSize() + 16);
titleFont.setBold(true);
title->setFont(titleFont);
auto *subtitle = new QLabel(tr("SSH / Telnet / 串口,一个窗口搞定所有远程工作"), page);
subtitle->setStyleSheet(QStringLiteral("color: #A29BC0;"));
auto *hint = new QLabel(tr("快捷键:Ctrl+N 新建 SSH · Ctrl+Shift+C 复制 · Ctrl+Shift+V 粘贴 · Ctrl+滚轮 缩放"), page);
hint->setStyleSheet(QStringLiteral("color: #6B6485;"));
auto *row = new QHBoxLayout;
auto *sshButton = new QPushButton(tr("新建 SSH 会话"), page);
sshButton->setDefault(true);
auto *telnetButton = new QPushButton(tr("新建 Telnet 会话"), page);
auto *serialButton = new QPushButton(tr("打开串口"), page);
row->addWidget(sshButton);
row->addWidget(telnetButton);
row->addWidget(serialButton);
row->addStretch(1);
layout->addStretch(1);
layout->addWidget(title);
layout->addWidget(subtitle);
layout->addSpacing(18);
layout->addLayout(row);
layout->addSpacing(24);
layout->addWidget(hint);
layout->addStretch(2);
connect(sshButton, &QPushButton::clicked, this, &MainWindow::newSshSession);
connect(telnetButton, &QPushButton::clicked, this, &MainWindow::newTelnetSession);
connect(serialButton, &QPushButton::clicked, this, &MainWindow::newSerialSession);
return page;
}
void MainWindow::buildUi()
{
m_stack = new QStackedWidget(this);
m_welcome = buildWelcomePage();
m_tabs = new QTabWidget(this);
m_tabs->setTabsClosable(true);
m_tabs->setMovable(true);
m_tabs->setDocumentMode(true);
m_stack->addWidget(m_welcome);
m_stack->addWidget(m_tabs);
m_stack->setCurrentWidget(m_welcome);
setCentralWidget(m_stack);
m_statusLabel = new QLabel(this);
statusBar()->addPermanentWidget(m_statusLabel);
connect(m_tabs, &QTabWidget::currentChanged, this, &MainWindow::onCurrentTabChanged);
connect(m_tabs, &QTabWidget::tabCloseRequested, this, &MainWindow::onTabCloseRequested);
}
void MainWindow::buildActions()
{
m_newSshAction = new QAction(tr("新建 SSH 会话..."), this);
m_newSshAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+N")));
connect(m_newSshAction, &QAction::triggered, this, &MainWindow::newSshSession);
m_newTelnetAction = new QAction(tr("新建 Telnet 会话..."), this);
connect(m_newTelnetAction, &QAction::triggered, this, &MainWindow::newTelnetSession);
m_newSerialAction = new QAction(tr("打开串口..."), this);
connect(m_newSerialAction, &QAction::triggered, this, &MainWindow::newSerialSession);
m_closeTabAction = new QAction(tr("关闭当前标签"), this);
m_closeTabAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+W")));
connect(m_closeTabAction, &QAction::triggered, this, &MainWindow::closeCurrentTab);
m_copyAction = new QAction(tr("复制"), this);
m_copyAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+C")));
connect(m_copyAction, &QAction::triggered, this, [this] {
if (TerminalWidget *terminal = currentTerminal())
terminal->copySelection();
});
m_pasteAction = new QAction(tr("粘贴"), this);
m_pasteAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+V")));
connect(m_pasteAction, &QAction::triggered, this, [this] {
if (TerminalWidget *terminal = currentTerminal())
terminal->pasteClipboard();
});
m_zoomInAction = new QAction(tr("放大字号"), this);
m_zoomInAction->setShortcut(QKeySequence(QStringLiteral("Ctrl++")));
connect(m_zoomInAction, &QAction::triggered, this, [this] {
if (TerminalWidget *terminal = currentTerminal())
terminal->zoomIn();
});
m_zoomOutAction = new QAction(tr("缩小字号"), this);
m_zoomOutAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+-")));
connect(m_zoomOutAction, &QAction::triggered, this, [this] {
if (TerminalWidget *terminal = currentTerminal())
terminal->zoomOut();
});
m_zoomResetAction = new QAction(tr("重置字号"), this);
m_zoomResetAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+0")));
connect(m_zoomResetAction, &QAction::triggered, this, [this] {
if (TerminalWidget *terminal = currentTerminal())
terminal->resetZoom();
});
auto *quitAction = new QAction(tr("退出"), this);
quitAction->setShortcut(QKeySequence::Quit);
connect(quitAction, &QAction::triggered, this, &QWidget::close);
auto *aboutAction = new QAction(tr("关于 VoiletXterm"), this);
connect(aboutAction, &QAction::triggered, this, &MainWindow::showAbout);
QMenu *fileMenu = menuBar()->addMenu(tr("文件(&F)"));
fileMenu->addAction(m_newSshAction);
fileMenu->addAction(m_newTelnetAction);
fileMenu->addAction(m_newSerialAction);
fileMenu->addSeparator();
fileMenu->addAction(m_closeTabAction);
fileMenu->addSeparator();
fileMenu->addAction(quitAction);
QMenu *editMenu = menuBar()->addMenu(tr("编辑(&E)"));
editMenu->addAction(m_copyAction);
editMenu->addAction(m_pasteAction);
QMenu *viewMenu = menuBar()->addMenu(tr("视图(&V)"));
viewMenu->addAction(m_zoomInAction);
viewMenu->addAction(m_zoomOutAction);
viewMenu->addAction(m_zoomResetAction);
QMenu *helpMenu = menuBar()->addMenu(tr("帮助(&H)"));
helpMenu->addAction(aboutAction);
QToolBar *toolbar = addToolBar(tr("主工具栏"));
toolbar->setMovable(false);
toolbar->setToolButtonStyle(Qt::ToolButtonTextOnly);
toolbar->addAction(m_newSshAction);
toolbar->addAction(m_newTelnetAction);
toolbar->addAction(m_newSerialAction);
toolbar->addSeparator();
toolbar->addAction(m_copyAction);
toolbar->addAction(m_pasteAction);
}
TerminalWidget *MainWindow::currentTerminal() const
{
return qobject_cast<TerminalWidget *>(m_tabs->currentWidget());
}
void MainWindow::openSpec(const SessionSpec &spec)
{
openSession(spec);
}
void MainWindow::newSshSession()
{
SessionDialog dialog(this);
dialog.setType(SessionSpec::Ssh);
if (dialog.exec() != QDialog::Accepted)
return;
if (dialog.shouldSave())
SessionStore::upsert(dialog.spec());
openSession(dialog.spec());
}
void MainWindow::newTelnetSession()
{
SessionDialog dialog(this);
dialog.setType(SessionSpec::Telnet);
if (dialog.exec() != QDialog::Accepted)
return;
if (dialog.shouldSave())
SessionStore::upsert(dialog.spec());
openSession(dialog.spec());
}
void MainWindow::newSerialSession()
{
SessionDialog dialog(this);
dialog.setType(SessionSpec::Serial);
if (dialog.exec() != QDialog::Accepted)
return;
if (dialog.shouldSave())
SessionStore::upsert(dialog.spec());
openSession(dialog.spec());
}
void MainWindow::openSession(const SessionSpec &spec)
{
auto *terminal = new TerminalWidget(this);
TerminalWidget *widget = terminal;
const int index = m_tabs->addTab(widget, spec.displayName());
m_tabs->setCurrentIndex(index);
m_stack->setCurrentWidget(m_tabs);
Transport *transport = createTransport(spec, widget);
if (!transport) {
delete widget;
return;
}
connect(widget, &TerminalWidget::sessionFinished, this, [this, widget](const QString &reason) {
widget->parser()->feed(QStringLiteral("\r\n\033[38;5;203m[会话已结束: %1]\033[0m\r\n")
.arg(reason)
.toUtf8());
widget->viewport()->update();
const int tabIndex = m_tabs->indexOf(widget);
if (tabIndex >= 0) {
QString title = m_tabs->tabText(tabIndex);
if (!title.endsWith(tr(" (已断开)")))
m_tabs->setTabText(tabIndex, title + tr(" (已断开)"));
if (tabIndex == m_tabs->currentIndex())
updateStatus();
}
widget->releaseTransport();
m_statusLabel->setText(reason);
});
connect(widget, &TerminalWidget::bell, this, [] { QApplication::beep(); });
connect(widget, &TerminalWidget::titleChanged, this, [this, widget](const QString &title) {
if (title.isEmpty())
return;
const int tabIndex = m_tabs->indexOf(widget);
if (tabIndex < 0)
return;
m_tabs->setTabText(tabIndex, title);
if (tabIndex == m_tabs->currentIndex())
updateStatus();
});
widget->setTransport(transport);
widget->setFocus();
updateStatus();
}
void MainWindow::closeCurrentTab()
{
if (m_tabs->count() == 0)
return;
onTabCloseRequested(m_tabs->currentIndex());
}
void MainWindow::onTabCloseRequested(int index)
{
QWidget *page = m_tabs->widget(index);
if (!page)
return;
m_tabs->removeTab(index);
page->deleteLater();
if (m_tabs->count() == 0) {
m_stack->setCurrentWidget(m_welcome);
m_statusLabel->clear();
setWindowTitle(QStringLiteral("VoiletXterm"));
}
}
void MainWindow::onCurrentTabChanged(int)
{
updateStatus();
if (TerminalWidget *terminal = currentTerminal())
terminal->setFocus();
}
void MainWindow::updateStatus()
{
TerminalWidget *terminal = currentTerminal();
if (!terminal) {
m_statusLabel->clear();
setWindowTitle(QStringLiteral("VoiletXterm"));
return;
}
Transport *transport = terminal->transport();
if (transport)
m_statusLabel->setText(transport->description());
const int index = m_tabs->currentIndex();
setWindowTitle(QStringLiteral("VoiletXterm — %1").arg(m_tabs->tabText(index)));
}
void MainWindow::showAbout()
{
QMessageBox::about(this, tr("关于 VoiletXterm"),
tr("<h3>VoiletXterm</h3>"
"<p>一个轻量的 SSH / Telnet / 串口终端工作台。</p>"
"<p>Qt %1 · 自研 VT100/xterm 终端内核</p>")
.arg(QString::fromLatin1(qVersion())));
}
void MainWindow::closeEvent(QCloseEvent *event)
{
QSettings settings;
settings.setValue(QStringLiteral("window/geometry"), saveGeometry());
event->accept();
}
} // namespace vx
+61
View File
@@ -0,0 +1,61 @@
#pragma once
#include "../session/SessionSpec.h"
#include <QMainWindow>
class QLabel;
class QStackedWidget;
class QTabWidget;
namespace vx {
class TerminalWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow() override;
public slots:
void newSshSession();
void newTelnetSession();
void newSerialSession();
void openSpec(const SessionSpec &spec);
private slots:
void closeCurrentTab();
void onCurrentTabChanged(int index);
void onTabCloseRequested(int index);
void showAbout();
protected:
void closeEvent(QCloseEvent *event) override;
private:
void buildUi();
void buildActions();
QWidget *buildWelcomePage();
void openSession(const SessionSpec &spec);
TerminalWidget *currentTerminal() const;
void updateStatus();
QStackedWidget *m_stack = nullptr;
QWidget *m_welcome = nullptr;
QTabWidget *m_tabs = nullptr;
QLabel *m_statusLabel = nullptr;
QAction *m_newSshAction = nullptr;
QAction *m_newTelnetAction = nullptr;
QAction *m_newSerialAction = nullptr;
QAction *m_closeTabAction = nullptr;
QAction *m_copyAction = nullptr;
QAction *m_pasteAction = nullptr;
QAction *m_zoomInAction = nullptr;
QAction *m_zoomOutAction = nullptr;
QAction *m_zoomResetAction = nullptr;
};
} // namespace vx
+365
View File
@@ -0,0 +1,365 @@
#include "SessionDialog.h"
#include "SessionStore.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFileDialog>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMessageBox>
#include <QPushButton>
#include <QSerialPortInfo>
#include <QSpinBox>
#include <QTabWidget>
#include <QVBoxLayout>
namespace vx {
SessionDialog::SessionDialog(QWidget *parent)
: QDialog(parent)
{
buildUi();
reloadSessions();
}
SessionDialog::SessionDialog(const SessionSpec &initial, QWidget *parent)
: QDialog(parent)
{
buildUi();
reloadSessions();
loadSpecIntoForm(initial);
}
void SessionDialog::buildUi()
{
setWindowTitle(tr("新建会话"));
resize(760, 480);
auto *root = new QHBoxLayout(this);
root->setContentsMargins(12, 12, 12, 12);
root->setSpacing(12);
// ---------------- saved sessions ----------------
auto *left = new QVBoxLayout;
left->addWidget(new QLabel(tr("已保存的会话"), this));
m_sessionList = new QListWidget(this);
m_sessionList->setMinimumWidth(200);
left->addWidget(m_sessionList, 1);
auto *listButtons = new QHBoxLayout;
m_deleteButton = new QPushButton(tr("删除"), this);
auto *saveButton = new QPushButton(tr("保存"), this);
auto *newButton = new QPushButton(tr("清空表单"), this);
listButtons->addWidget(m_deleteButton);
listButtons->addWidget(saveButton);
listButtons->addWidget(newButton);
listButtons->addStretch(1);
left->addLayout(listButtons);
root->addLayout(left, 0);
// ---------------- form ----------------
auto *right = new QVBoxLayout;
auto *nameRow = new QHBoxLayout;
nameRow->addWidget(new QLabel(tr("会话名称"), this));
m_nameEdit = new QLineEdit(this);
m_nameEdit->setPlaceholderText(tr("留空则使用主机名"));
nameRow->addWidget(m_nameEdit, 1);
right->addLayout(nameRow);
m_tabs = new QTabWidget(this);
// SSH page
auto *sshPage = new QWidget(this);
auto *sshForm = new QFormLayout(sshPage);
m_hostEdit = new QLineEdit(sshPage);
m_hostEdit->setPlaceholderText(QStringLiteral("example.com 或 192.168.1.10"));
m_portSpin = new QSpinBox(sshPage);
m_portSpin->setRange(1, 65535);
m_portSpin->setValue(22);
m_userEdit = new QLineEdit(sshPage);
m_userEdit->setPlaceholderText(tr("root"));
auto *keyRow = new QHBoxLayout;
m_keyEdit = new QLineEdit(sshPage);
m_keyEdit->setPlaceholderText(tr("可选,默认使用 ssh-agent 或 ~/.ssh/config"));
auto *browse = new QPushButton(tr("浏览"), sshPage);
keyRow->addWidget(m_keyEdit, 1);
keyRow->addWidget(browse);
m_jumpEdit = new QLineEdit(sshPage);
m_jumpEdit->setPlaceholderText(tr("可选,例如 user@bastion:22"));
m_commandEdit = new QLineEdit(sshPage);
m_commandEdit->setPlaceholderText(tr("可选,登录后立即执行"));
sshForm->addRow(tr("主机"), m_hostEdit);
sshForm->addRow(tr("端口"), m_portSpin);
sshForm->addRow(tr("用户名"), m_userEdit);
sshForm->addRow(tr("私钥"), keyRow);
sshForm->addRow(tr("跳板机"), m_jumpEdit);
sshForm->addRow(tr("远程命令"), m_commandEdit);
m_tabs->addTab(sshPage, tr("SSH"));
// Telnet page
auto *telnetPage = new QWidget(this);
auto *telnetForm = new QFormLayout(telnetPage);
m_telnetHostEdit = new QLineEdit(telnetPage);
m_telnetPortSpin = new QSpinBox(telnetPage);
m_telnetPortSpin->setRange(1, 65535);
m_telnetPortSpin->setValue(23);
telnetForm->addRow(tr("主机"), m_telnetHostEdit);
telnetForm->addRow(tr("端口"), m_telnetPortSpin);
m_tabs->addTab(telnetPage, tr("Telnet"));
// Serial page
auto *serialPage = new QWidget(this);
auto *serialForm = new QFormLayout(serialPage);
auto *portRow = new QHBoxLayout;
m_serialPortCombo = new QComboBox(serialPage);
m_serialPortCombo->setEditable(true);
auto *refresh = new QPushButton(tr("刷新"), serialPage);
portRow->addWidget(m_serialPortCombo, 1);
portRow->addWidget(refresh);
m_baudCombo = new QComboBox(serialPage);
m_baudCombo->setEditable(true);
for (int baud : {1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600})
m_baudCombo->addItem(QString::number(baud));
m_baudCombo->setCurrentText(QStringLiteral("115200"));
m_dataBitsCombo = new QComboBox(serialPage);
for (int bits : {5, 6, 7, 8})
m_dataBitsCombo->addItem(QString::number(bits));
m_dataBitsCombo->setCurrentText(QStringLiteral("8"));
m_parityCombo = new QComboBox(serialPage);
m_parityCombo->addItem(tr("无"), QStringLiteral("none"));
m_parityCombo->addItem(tr("偶校验"), QStringLiteral("even"));
m_parityCombo->addItem(tr("奇校验"), QStringLiteral("odd"));
m_parityCombo->addItem(tr("空格"), QStringLiteral("space"));
m_parityCombo->addItem(tr("标记"), QStringLiteral("mark"));
m_stopBitsCombo = new QComboBox(serialPage);
m_stopBitsCombo->addItem(QStringLiteral("1"), 1);
m_stopBitsCombo->addItem(QStringLiteral("2"), 2);
m_flowCombo = new QComboBox(serialPage);
m_flowCombo->addItem(tr("无"), QStringLiteral("none"));
m_flowCombo->addItem(tr("硬件 RTS/CTS"), QStringLiteral("hardware"));
m_flowCombo->addItem(tr("软件 XON/XOFF"), QStringLiteral("software"));
m_localEchoCheck = new QCheckBox(tr("本地回显"), serialPage);
serialForm->addRow(tr("串口"), portRow);
serialForm->addRow(tr("波特率"), m_baudCombo);
serialForm->addRow(tr("数据位"), m_dataBitsCombo);
serialForm->addRow(tr("校验"), m_parityCombo);
serialForm->addRow(tr("停止位"), m_stopBitsCombo);
serialForm->addRow(tr("流控"), m_flowCombo);
serialForm->addRow(QString(), m_localEchoCheck);
m_tabs->addTab(serialPage, tr("串口"));
right->addWidget(m_tabs, 1);
m_saveCheck = new QCheckBox(tr("连接后保存到会话列表"), this);
m_saveCheck->setChecked(true);
right->addWidget(m_saveCheck);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttons->button(QDialogButtonBox::Ok)->setText(tr("连接"));
buttons->button(QDialogButtonBox::Cancel)->setText(tr("取消"));
right->addWidget(buttons);
root->addLayout(right, 1);
connect(buttons, &QDialogButtonBox::accepted, this, &SessionDialog::onAccept);
connect(buttons, &QDialogButtonBox::rejected, this, &SessionDialog::reject);
connect(m_sessionList, &QListWidget::itemDoubleClicked, this, &SessionDialog::onSessionActivated);
connect(m_sessionList, &QListWidget::itemActivated, this, &SessionDialog::onSessionActivated);
connect(m_deleteButton, &QPushButton::clicked, this, &SessionDialog::onDeleteClicked);
connect(saveButton, &QPushButton::clicked, this, &SessionDialog::onSaveClicked);
connect(newButton, &QPushButton::clicked, this, [this] {
m_nameEdit->clear();
m_hostEdit->clear();
m_userEdit->clear();
m_keyEdit->clear();
m_jumpEdit->clear();
m_commandEdit->clear();
m_telnetHostEdit->clear();
});
connect(browse, &QPushButton::clicked, this, &SessionDialog::onBrowseKey);
connect(refresh, &QPushButton::clicked, this, &SessionDialog::onRefreshPorts);
onRefreshPorts();
}
void SessionDialog::onRefreshPorts()
{
const QString current = m_serialPortCombo->currentText();
m_serialPortCombo->clear();
const auto ports = QSerialPortInfo::availablePorts();
for (const QSerialPortInfo &info : ports) {
QString label = info.systemLocation();
if (!info.description().isEmpty())
label += QStringLiteral(" - %1").arg(info.description());
m_serialPortCombo->addItem(label, info.systemLocation());
}
if (m_serialPortCombo->count() == 0)
m_serialPortCombo->addItem(QStringLiteral("/dev/ttyUSB0"), QStringLiteral("/dev/ttyUSB0"));
if (!current.isEmpty())
m_serialPortCombo->setCurrentText(current);
syncPortDisplay();
}
void SessionDialog::syncPortDisplay()
{
m_serialPortCombo->setEditText(m_serialPortCombo->currentData().toString().isEmpty()
? m_serialPortCombo->currentText()
: m_serialPortCombo->currentData().toString());
}
void SessionDialog::onBrowseKey()
{
const QString file = QFileDialog::getOpenFileName(this, tr("选择私钥"),
QDir::homePath() + QStringLiteral("/.ssh"));
if (!file.isEmpty())
m_keyEdit->setText(file);
}
void SessionDialog::reloadSessions()
{
m_sessionList->clear();
const auto sessions = SessionStore::load();
for (const SessionSpec &spec : sessions) {
auto *item = new QListWidgetItem(spec.displayName(), m_sessionList);
item->setData(Qt::UserRole, int(spec.type));
item->setToolTip(spec.summary());
}
}
void SessionDialog::onSessionActivated()
{
QListWidgetItem *item = m_sessionList->currentItem();
if (!item)
return;
const auto sessions = SessionStore::load();
const int row = m_sessionList->row(item);
if (row < 0 || row >= sessions.size())
return;
loadSpecIntoForm(sessions.at(row));
}
void SessionDialog::loadSpecIntoForm(const SessionSpec &spec)
{
m_nameEdit->setText(spec.name);
m_spec = spec;
switch (spec.type) {
case SessionSpec::Ssh:
m_tabs->setCurrentIndex(0);
m_hostEdit->setText(spec.host);
m_portSpin->setValue(spec.port > 0 ? spec.port : 22);
m_userEdit->setText(spec.user);
m_keyEdit->setText(spec.keyFile);
m_jumpEdit->setText(spec.jumpHost);
m_commandEdit->setText(spec.remoteCommand);
break;
case SessionSpec::Telnet:
m_tabs->setCurrentIndex(1);
m_telnetHostEdit->setText(spec.host);
m_telnetPortSpin->setValue(spec.port > 0 ? spec.port : 23);
break;
case SessionSpec::Serial:
m_tabs->setCurrentIndex(2);
m_serialPortCombo->setEditText(spec.serialPort);
m_baudCombo->setCurrentText(QString::number(spec.baudRate));
m_dataBitsCombo->setCurrentText(QString::number(spec.dataBits));
m_stopBitsCombo->setCurrentText(QString::number(spec.stopBits));
for (int i = 0; i < m_parityCombo->count(); ++i) {
if (m_parityCombo->itemData(i).toString() == spec.parity) {
m_parityCombo->setCurrentIndex(i);
break;
}
}
for (int i = 0; i < m_flowCombo->count(); ++i) {
if (m_flowCombo->itemData(i).toString() == spec.flowControl) {
m_flowCombo->setCurrentIndex(i);
break;
}
}
m_localEchoCheck->setChecked(spec.localEcho);
break;
}
}
void SessionDialog::setType(int type)
{
m_tabs->setCurrentIndex(qBound(0, type, 2));
}
void SessionDialog::applyPageToSpec()
{
const int page = m_tabs->currentIndex();
if (page == 0) {
m_spec.type = SessionSpec::Ssh;
m_spec.host = m_hostEdit->text().trimmed();
m_spec.port = m_portSpin->value();
m_spec.user = m_userEdit->text().trimmed();
m_spec.keyFile = m_keyEdit->text().trimmed();
m_spec.jumpHost = m_jumpEdit->text().trimmed();
m_spec.remoteCommand = m_commandEdit->text().trimmed();
} else if (page == 1) {
m_spec.type = SessionSpec::Telnet;
m_spec.host = m_telnetHostEdit->text().trimmed();
m_spec.port = m_telnetPortSpin->value();
} else {
m_spec.type = SessionSpec::Serial;
const QString data = m_serialPortCombo->currentData().toString();
m_spec.serialPort = data.isEmpty() ? m_serialPortCombo->currentText().trimmed() : data;
m_spec.baudRate = m_baudCombo->currentText().toInt();
m_spec.dataBits = m_dataBitsCombo->currentText().toInt();
m_spec.stopBits = m_stopBitsCombo->currentData().toInt();
m_spec.parity = m_parityCombo->currentData().toString();
m_spec.flowControl = m_flowCombo->currentData().toString();
m_spec.localEcho = m_localEchoCheck->isChecked();
}
m_spec.name = m_nameEdit->text().trimmed();
}
void SessionDialog::onAccept()
{
applyPageToSpec();
if (m_spec.type == SessionSpec::Serial) {
if (m_spec.serialPort.isEmpty()) {
QMessageBox::warning(this, tr("参数不完整"), tr("请选择或填写串口设备。"));
return;
}
} else if (m_spec.host.isEmpty()) {
QMessageBox::warning(this, tr("参数不完整"), tr("请填写主机地址。"));
return;
}
if (m_spec.name.isEmpty())
m_spec.name = m_spec.displayName();
accept();
}
bool SessionDialog::shouldSave() const
{
return m_saveCheck->isChecked();
}
void SessionDialog::onSaveClicked()
{
applyPageToSpec();
if (m_spec.name.isEmpty())
m_spec.name = m_spec.displayName();
SessionStore::upsert(m_spec);
reloadSessions();
}
void SessionDialog::onDeleteClicked()
{
QListWidgetItem *item = m_sessionList->currentItem();
if (!item)
return;
SessionStore::removeByName(item->text());
reloadSessions();
}
} // namespace vx
+75
View File
@@ -0,0 +1,75 @@
#pragma once
#include "../session/SessionSpec.h"
#include <QDialog>
class QCheckBox;
class QComboBox;
class QLineEdit;
class QListWidget;
class QPushButton;
class QSpinBox;
class QTabWidget;
namespace vx {
class SessionDialog : public QDialog
{
Q_OBJECT
public:
explicit SessionDialog(QWidget *parent = nullptr);
SessionDialog(const SessionSpec &initial, QWidget *parent = nullptr);
SessionSpec spec() const { return m_spec; }
bool shouldSave() const;
public slots:
void setType(int type);
private slots:
void onAccept();
void onSaveClicked();
void onDeleteClicked();
void onSessionActivated();
void onBrowseKey();
void onRefreshPorts();
private:
void buildUi();
void reloadSessions();
void loadSpecIntoForm(const SessionSpec &spec);
void applyPageToSpec();
void syncPortDisplay();
SessionSpec m_spec;
QLineEdit *m_nameEdit = nullptr;
QTabWidget *m_tabs = nullptr;
QListWidget *m_sessionList = nullptr;
QPushButton *m_deleteButton = nullptr;
QCheckBox *m_saveCheck = nullptr;
// SSH
QLineEdit *m_hostEdit = nullptr;
QSpinBox *m_portSpin = nullptr;
QLineEdit *m_userEdit = nullptr;
QLineEdit *m_keyEdit = nullptr;
QLineEdit *m_jumpEdit = nullptr;
QLineEdit *m_commandEdit = nullptr;
// Telnet
QLineEdit *m_telnetHostEdit = nullptr;
QSpinBox *m_telnetPortSpin = nullptr;
// Serial
QComboBox *m_serialPortCombo = nullptr;
QComboBox *m_baudCombo = nullptr;
QComboBox *m_dataBitsCombo = nullptr;
QComboBox *m_parityCombo = nullptr;
QComboBox *m_stopBitsCombo = nullptr;
QComboBox *m_flowCombo = nullptr;
QCheckBox *m_localEchoCheck = nullptr;
};
} // namespace vx
+76
View File
@@ -0,0 +1,76 @@
#include "SessionStore.h"
#include <QJsonDocument>
#include <QSettings>
namespace vx {
namespace {
const char *kArrayKey = "sessions";
}
QVector<SessionSpec> SessionStore::load()
{
QVector<SessionSpec> sessions;
QSettings settings;
const int count = settings.beginReadArray(QLatin1String(kArrayKey));
for (int i = 0; i < count; ++i) {
settings.setArrayIndex(i);
const QByteArray json = settings.value(QStringLiteral("json")).toByteArray();
if (json.isEmpty())
continue;
const QJsonDocument doc = QJsonDocument::fromJson(json);
if (doc.isObject())
sessions.append(SessionSpec::fromJson(doc.object()));
}
settings.endArray();
return sessions;
}
void SessionStore::saveAll(const QVector<SessionSpec> &sessions)
{
QSettings settings;
settings.beginWriteArray(QLatin1String(kArrayKey), sessions.size());
for (int i = 0; i < sessions.size(); ++i) {
settings.setArrayIndex(i);
const QJsonDocument doc(sessions.at(i).toJson());
settings.setValue(QStringLiteral("json"), doc.toJson(QJsonDocument::Compact));
}
settings.endArray();
settings.sync();
}
void SessionStore::upsert(const SessionSpec &spec)
{
QVector<SessionSpec> sessions = load();
const QString key = spec.name.isEmpty() ? spec.displayName() : spec.name;
bool replaced = false;
for (SessionSpec &existing : sessions) {
const QString existingKey = existing.name.isEmpty() ? existing.displayName() : existing.name;
if (existingKey == key) {
existing = spec;
existing.name = key;
replaced = true;
break;
}
}
if (!replaced) {
SessionSpec copy = spec;
copy.name = key;
sessions.append(copy);
}
saveAll(sessions);
}
void SessionStore::removeByName(const QString &name)
{
QVector<SessionSpec> sessions = load();
for (int i = sessions.size() - 1; i >= 0; --i) {
const QString key = sessions.at(i).name.isEmpty() ? sessions.at(i).displayName() : sessions.at(i).name;
if (key == name)
sessions.remove(i);
}
saveAll(sessions);
}
} // namespace vx
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "../session/SessionSpec.h"
#include <QVector>
namespace vx {
class SessionStore
{
public:
static QVector<SessionSpec> load();
static void saveAll(const QVector<SessionSpec> &sessions);
static void upsert(const SessionSpec &spec);
static void removeByName(const QString &name);
};
} // namespace vx
+79
View File
@@ -0,0 +1,79 @@
#include "Style.h"
#include <QApplication>
#include <QPalette>
#include <QStyleFactory>
namespace vx {
void applyDarkStyle(QApplication &app)
{
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
QPalette palette;
palette.setColor(QPalette::Window, QColor(0x14, 0x12, 0x1C));
palette.setColor(QPalette::WindowText, QColor(0xE4, 0xE0, 0xF0));
palette.setColor(QPalette::Base, QColor(0x1B, 0x18, 0x26));
palette.setColor(QPalette::AlternateBase, QColor(0x24, 0x20, 0x33));
palette.setColor(QPalette::ToolTipBase, QColor(0x1B, 0x18, 0x26));
palette.setColor(QPalette::ToolTipText, QColor(0xE4, 0xE0, 0xF0));
palette.setColor(QPalette::Text, QColor(0xE4, 0xE0, 0xF0));
palette.setColor(QPalette::Button, QColor(0x24, 0x20, 0x33));
palette.setColor(QPalette::ButtonText, QColor(0xE4, 0xE0, 0xF0));
palette.setColor(QPalette::BrightText, QColor(0xFF, 0x7B, 0x86));
palette.setColor(QPalette::Highlight, QColor(0x6D, 0x4F, 0xD1));
palette.setColor(QPalette::HighlightedText, QColor(0xFF, 0xFF, 0xFF));
palette.setColor(QPalette::Link, QColor(0xA7, 0x8B, 0xFA));
palette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x6B, 0x64, 0x85));
palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor(0x6B, 0x64, 0x85));
app.setPalette(palette);
app.setStyleSheet(QStringLiteral(R"(
QMainWindow, QDialog { background: #14121C; }
QTabWidget::pane { border: 0; }
QTabBar::tab {
background: #1B1826; color: #A29BC0; padding: 6px 16px; margin-right: 2px;
border-top-left-radius: 4px; border-top-right-radius: 4px;
}
QTabBar::tab:selected { background: #14121C; color: #E4E0F0; border-top: 2px solid #A78BFA; }
QTabBar::tab:hover:!selected { background: #252134; }
QToolBar { background: #1B1826; border: 0; spacing: 4px; padding: 4px; }
QToolBar QToolButton { color: #E4E0F0; padding: 5px 10px; border-radius: 4px; }
QToolBar QToolButton:hover { background: #2C2740; }
QMenuBar { background: #1B1826; color: #E4E0F0; }
QMenuBar::item { padding: 4px 10px; background: transparent; }
QMenuBar::item:selected { background: #2C2740; border-radius: 4px; }
QMenu { background: #1B1826; color: #E4E0F0; border: 1px solid #2C2740; }
QMenu::item { padding: 6px 26px; }
QMenu::item:selected { background: #4A3F7A; }
QStatusBar { background: #1B1826; color: #A29BC0; }
QStatusBar::item { border: 0; }
QScrollBar:vertical { background: #14121C; width: 10px; margin: 0; }
QScrollBar::handle:vertical { background: #3B3555; border-radius: 5px; min-height: 24px; }
QScrollBar::handle:vertical:hover { background: #4A3F7A; }
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }
QLineEdit, QSpinBox, QComboBox {
background: #1B1826; color: #E4E0F0; border: 1px solid #2C2740;
border-radius: 4px; padding: 5px 8px; selection-background-color: #6D4FD1;
}
QLineEdit:focus, QSpinBox:focus, QComboBox:focus { border: 1px solid #A78BFA; }
QComboBox QAbstractItemView { background: #1B1826; color: #E4E0F0; selection-background-color: #4A3F7A; }
QPushButton {
background: #2C2740; color: #E4E0F0; border: 0; padding: 6px 16px;
border-radius: 4px; min-width: 64px;
}
QPushButton:hover { background: #3B3555; }
QPushButton:pressed { background: #4A3F7A; }
QPushButton:default { background: #6D4FD1; }
QPushButton:default:hover { background: #7C5FE0; }
QListWidget { background: #1B1826; color: #E4E0F0; border: 1px solid #2C2740; border-radius: 6px; }
QListWidget::item { padding: 6px 8px; }
QListWidget::item:selected { background: #4A3F7A; border-radius: 4px; }
QLabel { color: #CFC8E4; }
QCheckBox { color: #CFC8E4; }
QToolTip { background: #2C2740; color: #E4E0F0; border: 1px solid #4A3F7A; }
)"));
}
} // namespace vx
+9
View File
@@ -0,0 +1,9 @@
#pragma once
class QApplication;
namespace vx {
void applyDarkStyle(QApplication &app);
} // namespace vx
+231
View File
@@ -0,0 +1,231 @@
// Minimal self checks for the terminal core (no GUI required).
#include <QByteArray>
#include <QString>
#include <cstdio>
#include "core/Screen.h"
#include "core/VtParser.h"
using namespace vx;
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
static void checkEq(const QString &got, const QString &want, const char *what, int line)
{
if (got != want) {
std::printf("FAIL %s:%d %s\n got: [%s]\n exp: [%s]\n",
__FILE__, line, what, qPrintable(got), qPrintable(want));
++g_failures;
}
}
#define CHECK_EQ(got, want) checkEq((got), (want), #got, __LINE__)
struct Fixture {
Screen screen;
VtParser parser;
explicit Fixture(int cols = 20, int rows = 5)
: parser(&screen)
{
screen.initialize(cols, rows);
}
void feed(const char *text) { parser.feed(QByteArray(text)); }
const Cell *row(int r) const { return screen.lineData(screen.historyCount() + r); }
QString dump() const
{
QString out;
for (int r = 0; r < screen.rows(); ++r) {
const Cell *cells = row(r);
QString line;
for (int c = 0; c < screen.columns(); ++c) {
if (cells[c].isWideTail())
continue;
line += codePointToString(cells[c].ch);
line += screen.graphemeText(cells[c].extra);
}
while (line.endsWith(QLatin1Char(' ')))
line.chop(1);
out += line;
if (r + 1 < screen.rows())
out += QLatin1Char('\n');
}
while (out.endsWith(QLatin1Char('\n')))
out.chop(1);
return out;
}
};
static void testPlainText()
{
Fixture f;
f.feed("hello");
CHECK_EQ(f.dump(), QStringLiteral("hello"));
CHECK(f.screen.cursorCol() == 5);
CHECK(f.screen.cursorRow() == 0);
}
static void testNewline()
{
Fixture f;
f.feed("abc\r\ndef");
CHECK_EQ(f.dump(), QStringLiteral("abc\ndef"));
CHECK(f.screen.cursorRow() == 1);
CHECK(f.screen.cursorCol() == 3);
}
static void testCursorPosition()
{
Fixture f;
f.feed("\x1b[2;3HX");
CHECK_EQ(f.dump(), QStringLiteral("\n X"));
CHECK(f.screen.cursorRow() == 1);
CHECK(f.screen.cursorCol() == 3);
}
static void testSgrColors()
{
Fixture f;
f.feed("\x1b[1;31mR\x1b[0mN");
const Cell *cells = f.row(0);
CHECK(cells[0].fg == paletteColor(1));
CHECK((cells[0].attr & AttrBold) != 0);
CHECK(cells[1].fg == kColorDefault);
CHECK(cells[1].attr == 0);
}
static void testTrueColor()
{
Fixture f;
f.feed("\x1b[38;2;10;20;30mX");
CHECK(f.row(0)[0].fg == rgbColor(10, 20, 30));
}
static void testErase()
{
Fixture f;
f.feed("abc\r\ndef");
f.feed("\x1b[2J");
CHECK_EQ(f.dump(), QString());
}
static void testWideChars()
{
Fixture f;
f.feed("\xe4\xb8\xad\xe6\x96\x87");
const Cell *cells = f.row(0);
CHECK(cells[0].ch == 0x4E2D);
CHECK(cells[0].isWideHead());
CHECK(cells[1].isWideTail());
CHECK(cells[2].ch == 0x6587);
CHECK(f.screen.cursorCol() == 4);
CHECK(cells[0].attr & AttrWide);
}
static void testWrap()
{
Fixture f(20, 5);
QString text;
for (int i = 0; i < 25; ++i)
text += QLatin1Char('x');
f.feed(text.toUtf8().constData());
CHECK(f.screen.cursorRow() == 1);
CHECK(f.screen.cursorCol() == 5);
const Cell *cells = f.row(0);
CHECK(cells[19].ch == U'x');
CHECK(f.row(1)[4].ch == U'x');
}
static void testScrollback()
{
Fixture f(20, 5);
for (int i = 0; i < 10; ++i)
f.feed(QByteArray::number(i).append("\r\n").constData());
CHECK(f.screen.historyCount() == 6);
CHECK(f.screen.totalLines() == 11);
const Cell *first = f.screen.lineData(0);
CHECK(first[0].ch == U'0');
}
static void testAltScreen()
{
Fixture f;
f.feed("main");
f.feed("\x1b[?1049h");
f.feed("alt");
CHECK_EQ(f.dump(), QStringLiteral("alt"));
CHECK(f.screen.alternateScreen());
f.feed("\x1b[?1049l");
CHECK_EQ(f.dump(), QStringLiteral("main"));
CHECK(!f.screen.alternateScreen());
}
static void testDecGraphics()
{
Fixture f;
f.feed("\x1b(0q\x1b(Bq");
CHECK(f.row(0)[0].ch == 0x2500);
CHECK(f.row(0)[1].ch == U'q');
}
static void testInsertDeleteChars()
{
Fixture f;
f.feed("abcdef\x1b[1;3H");
f.feed("\x1b[2@");
CHECK_EQ(f.dump(), QStringLiteral("ab cdef"));
f.feed("\x1b[2P");
CHECK_EQ(f.dump(), QStringLiteral("abcdef"));
}
static void testScrollRegion()
{
Fixture f(10, 5);
f.feed("a\r\nb\r\nc\r\nd\r\ne");
f.feed("\x1b[2;4r");
f.feed("\x1b[4;1H\r\n");
// line 1 ("b") scrolled out of the region
CHECK(f.row(1)[0].ch == U'c');
CHECK(f.row(0)[0].ch == U'a');
}
static void testReverseIndex()
{
Fixture f(10, 4);
f.feed("\x1b[1;1H\x1bM");
CHECK_EQ(f.dump(), QString());
}
int main()
{
testPlainText();
testNewline();
testCursorPosition();
testSgrColors();
testTrueColor();
testErase();
testWideChars();
testWrap();
testScrollback();
testAltScreen();
testDecGraphics();
testInsertDeleteChars();
testScrollRegion();
testReverseIndex();
if (g_failures == 0)
std::printf("all core tests passed\n");
else
std::printf("%d core test(s) failed\n", g_failures);
return g_failures == 0 ? 0 : 1;
}
+186
View File
@@ -0,0 +1,186 @@
// Renders a TerminalWidget offscreen and inspects the produced pixels, so the
// painting path can be verified without a display.
#include <QApplication>
#include <QImage>
#include <QScrollBar>
#include <QtTest/QTest>
#include <cstdio>
#include "session/Transport.h"
#include "core/Screen.h"
#include "core/VtParser.h"
#include "term/TerminalTheme.h"
#include "term/TerminalWidget.h"
using namespace vx;
static int g_failures = 0;
static void check(bool ok, const char *what, int line)
{
if (!ok) {
std::printf("FAIL %s:%d %s\n", __FILE__, line, what);
++g_failures;
}
}
#define CHECK(cond) check((cond), #cond, __LINE__)
class RecordingTransport : public Transport
{
public:
QByteArray written;
bool start(int, int) override { return true; }
void write(const QByteArray &data) override { written += data; }
QString description() const override { return QStringLiteral("recording"); }
};
static bool nearColor(const QColor &a, const QColor &b, int tolerance = 24)
{
return qAbs(a.red() - b.red()) <= tolerance && qAbs(a.green() - b.green()) <= tolerance
&& qAbs(a.blue() - b.blue()) <= tolerance;
}
static int countNear(const QImage &image, const QRect &rect, const QColor &color, int tolerance = 24)
{
int count = 0;
const QRect r = rect.intersected(image.rect());
for (int y = r.top(); y <= r.bottom(); ++y) {
for (int x = r.left(); x <= r.right(); ++x) {
if (nearColor(image.pixelColor(x, y), color, tolerance))
++count;
}
}
return count;
}
static int countNonBackground(const QImage &image, const QRect &rect, const QColor &bg)
{
int count = 0;
const QRect r = rect.intersected(image.rect());
for (int y = r.top(); y <= r.bottom(); ++y) {
for (int x = r.left(); x <= r.right(); ++x) {
if (!nearColor(image.pixelColor(x, y), bg, 6))
++count;
}
}
return count;
}
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
TerminalWidget widget;
widget.resize(820, 420);
widget.show();
QCoreApplication::processEvents();
const TerminalTheme theme = widget.theme();
const int cols = widget.screen()->columns();
const int rows = widget.screen()->rows();
CHECK(cols > 40);
CHECK(rows > 10);
const qreal cellW = qreal(widget.viewport()->width()) / cols;
const qreal cellH = qreal(widget.viewport()->height()) / rows;
CHECK(cellW > 4 && cellH > 8);
const auto cellRect = [&](int row, int col, int span = 1) {
return QRect(int(col * cellW), int(row * cellH), int(span * cellW), int(cellH));
};
// --- empty screen is uniformly background ---
QImage blank = widget.grab().toImage();
CHECK(blank.width() > 0);
// The home position shows a block cursor, so start checking below it.
QRect belowCursor(0, int(cellH), widget.viewport()->width(),
widget.viewport()->height() - int(cellH));
CHECK(countNonBackground(blank, belowCursor, theme.background) == 0);
// --- plain text draws ink ---
widget.parser()->feed("hello world");
QCoreApplication::processEvents();
QImage textImage = widget.grab().toImage();
CHECK(countNonBackground(textImage, textImage.rect(), theme.background) > 200);
// --- SGR color lands on the right cell ---
widget.parser()->feed("\r\n\033[31mR\033[0m");
QCoreApplication::processEvents();
QImage colorImage = widget.grab().toImage();
CHECK(countNear(colorImage, cellRect(1, 0), theme.ansi[1]) > 5);
// --- wide CJK glyph occupies two cells ---
widget.parser()->feed("\r\n\346\226\207"); // 文
QCoreApplication::processEvents();
QImage cjkImage = widget.grab().toImage();
CHECK(countNonBackground(cjkImage, cellRect(2, 0), theme.background) > 10);
CHECK(countNonBackground(cjkImage, cellRect(2, 1), theme.background) > 10);
// --- reverse video paints the cell background ---
widget.parser()->feed("\r\n\033[7mZ\033[0m");
QCoreApplication::processEvents();
QImage reverseImage = widget.grab().toImage();
CHECK(countNear(reverseImage, cellRect(3, 0), theme.foreground, 40) > 40);
// --- the cursor is painted where the shell left it ---
QImage cursorImage = widget.grab().toImage();
CHECK(countNear(cursorImage, cursorImage.rect(), theme.cursor, 30) > 30);
// --- selection uses the highlight color ---
widget.selectAll();
QCoreApplication::processEvents();
QImage selectionImage = widget.grab().toImage();
CHECK(countNear(selectionImage, selectionImage.rect(), theme.selection, 30) > 500);
// --- keyboard input is encoded the way a shell expects ---
{
RecordingTransport *recorder = new RecordingTransport;
widget.setTransport(recorder);
QTest::keyClicks(&widget, "hi");
CHECK(recorder->written == QByteArray("hi"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_Return);
CHECK(recorder->written == QByteArray("\r"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_Backspace);
CHECK(recorder->written == QByteArray("\x7f"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_Up);
CHECK(recorder->written == QByteArray("\033[A"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_C, Qt::ControlModifier);
CHECK(recorder->written == QByteArray(1, '\x03'));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_A, Qt::AltModifier);
CHECK(recorder->written == QByteArray("\033a", 2));
recorder->written.clear();
widget.screen()->setPrivateMode(1, true); // application cursor keys
QTest::keyClick(&widget, Qt::Key_Left);
CHECK(recorder->written == QByteArray("\033OD", 3));
recorder->written.clear();
widget.releaseTransport();
}
// --- scrolling fills the scrollbar range ---
widget.screen()->clearScrollback();
for (int i = 0; i < 80; ++i)
widget.parser()->feed("line\r\n");
QCoreApplication::processEvents();
CHECK(widget.screen()->historyCount() > 0);
if (g_failures == 0)
std::printf("widget render checks passed (%dx%d cells, %.1fx%.1f px)\n", cols, rows,
double(cellW), double(cellH));
else
std::printf("%d widget check(s) failed\n", g_failures);
return g_failures == 0 ? 0 : 1;
}
+13
View File
@@ -0,0 +1,13 @@
QT = core
CONFIG += console c++17
CONFIG -= app_bundle
TARGET = vx_tests
TEMPLATE = app
INCLUDEPATH += ../src
SOURCES += test_core.cpp \
../src/core/Screen.cpp \
../src/core/CharWidth.cpp \
../src/core/Palette.cpp \
../src/core/VtParser.cpp
HEADERS += ../src/core/Screen.h \
../src/core/VtParser.h
+17
View File
@@ -0,0 +1,17 @@
QT += core gui widgets testlib
CONFIG += console c++17
CONFIG -= app_bundle
TARGET = vx_widget_test
TEMPLATE = app
INCLUDEPATH += ../src
SOURCES += test_widget.cpp \
../src/core/Screen.cpp \
../src/core/CharWidth.cpp \
../src/core/Palette.cpp \
../src/core/VtParser.cpp \
../src/term/TerminalTheme.cpp \
../src/term/TerminalWidget.cpp
HEADERS += ../src/core/Screen.h \
../src/core/VtParser.h \
../src/term/TerminalWidget.h \
../src/session/Transport.h