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:
+12
@@ -0,0 +1,12 @@
|
||||
build/
|
||||
.tmp/
|
||||
Makefile
|
||||
*.o
|
||||
moc_*.cpp
|
||||
moc_*.h
|
||||
qrc_*.cpp
|
||||
ui_*.h
|
||||
.qmake.stash
|
||||
*.pro.user
|
||||
*.autosave
|
||||
.vscode/
|
||||
@@ -0,0 +1,97 @@
|
||||
# VoiletXterm
|
||||
|
||||
> 轻量的 SSH / Telnet / 串口终端工作台。Qt 5 + qmake,终端内核全部自研。
|
||||
|
||||

|
||||
|
||||
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
|
||||
@@ -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 |
@@ -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 面板、标签内搜索、分屏
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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> ¶ms)
|
||||
{
|
||||
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
|
||||
@@ -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> ¶ms);
|
||||
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
|
||||
@@ -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 = [¶ms](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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "SessionSpec.h"
|
||||
|
||||
namespace vx {
|
||||
|
||||
class Transport;
|
||||
|
||||
Transport *createTransport(const SessionSpec &spec, QObject *parent);
|
||||
|
||||
} // namespace vx
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
class QApplication;
|
||||
|
||||
namespace vx {
|
||||
|
||||
void applyDarkStyle(QApplication &app);
|
||||
|
||||
} // namespace vx
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user