From 580abbcadf1eefb78f28ae7b70370f9918aad22f Mon Sep 17 00:00:00 2001 From: liyepan Date: Fri, 23 Oct 2026 17:38:16 +0800 Subject: [PATCH] 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 --- .gitignore | 12 + README.md | 97 +++ VoiletXterm.pro | 51 ++ docs/screenshot.png | Bin 0 -> 33431 bytes docs/tech-stack.md | 50 ++ src/core/Cell.h | 71 ++ src/core/CharWidth.cpp | 125 ++++ src/core/CharWidth.h | 14 + src/core/Palette.cpp | 33 + src/core/Palette.h | 10 + src/core/Screen.cpp | 897 +++++++++++++++++++++++++ src/core/Screen.h | 169 +++++ src/core/VtParser.cpp | 493 ++++++++++++++ src/core/VtParser.h | 57 ++ src/main.cpp | 88 +++ src/session/PtyProcess.cpp | 224 +++++++ src/session/PtyProcess.h | 50 ++ src/session/SerialTransport.cpp | 98 +++ src/session/SerialTransport.h | 28 + src/session/SessionFactory.cpp | 23 + src/session/SessionFactory.h | 11 + src/session/SessionSpec.cpp | 80 +++ src/session/SessionSpec.h | 39 ++ src/session/SshTransport.cpp | 97 +++ src/session/SshTransport.h | 33 + src/session/TelnetTransport.cpp | 197 ++++++ src/session/TelnetTransport.h | 42 ++ src/session/Transport.h | 40 ++ src/term/TerminalTheme.cpp | 34 + src/term/TerminalTheme.h | 18 + src/term/TerminalWidget.cpp | 1104 +++++++++++++++++++++++++++++++ src/term/TerminalWidget.h | 156 +++++ src/ui/MainWindow.cpp | 350 ++++++++++ src/ui/MainWindow.h | 61 ++ src/ui/SessionDialog.cpp | 365 ++++++++++ src/ui/SessionDialog.h | 75 +++ src/ui/SessionStore.cpp | 76 +++ src/ui/SessionStore.h | 18 + src/ui/Style.cpp | 79 +++ src/ui/Style.h | 9 + tests/test_core.cpp | 231 +++++++ tests/test_widget.cpp | 186 ++++++ tests/tests.pro | 13 + tests/widgettest.pro | 17 + 44 files changed, 5921 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 VoiletXterm.pro create mode 100644 docs/screenshot.png create mode 100644 docs/tech-stack.md create mode 100644 src/core/Cell.h create mode 100644 src/core/CharWidth.cpp create mode 100644 src/core/CharWidth.h create mode 100644 src/core/Palette.cpp create mode 100644 src/core/Palette.h create mode 100644 src/core/Screen.cpp create mode 100644 src/core/Screen.h create mode 100644 src/core/VtParser.cpp create mode 100644 src/core/VtParser.h create mode 100644 src/main.cpp create mode 100644 src/session/PtyProcess.cpp create mode 100644 src/session/PtyProcess.h create mode 100644 src/session/SerialTransport.cpp create mode 100644 src/session/SerialTransport.h create mode 100644 src/session/SessionFactory.cpp create mode 100644 src/session/SessionFactory.h create mode 100644 src/session/SessionSpec.cpp create mode 100644 src/session/SessionSpec.h create mode 100644 src/session/SshTransport.cpp create mode 100644 src/session/SshTransport.h create mode 100644 src/session/TelnetTransport.cpp create mode 100644 src/session/TelnetTransport.h create mode 100644 src/session/Transport.h create mode 100644 src/term/TerminalTheme.cpp create mode 100644 src/term/TerminalTheme.h create mode 100644 src/term/TerminalWidget.cpp create mode 100644 src/term/TerminalWidget.h create mode 100644 src/ui/MainWindow.cpp create mode 100644 src/ui/MainWindow.h create mode 100644 src/ui/SessionDialog.cpp create mode 100644 src/ui/SessionDialog.h create mode 100644 src/ui/SessionStore.cpp create mode 100644 src/ui/SessionStore.h create mode 100644 src/ui/Style.cpp create mode 100644 src/ui/Style.h create mode 100644 tests/test_core.cpp create mode 100644 tests/test_widget.cpp create mode 100644 tests/tests.pro create mode 100644 tests/widgettest.pro diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0fd2da --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +build/ +.tmp/ +Makefile +*.o +moc_*.cpp +moc_*.h +qrc_*.cpp +ui_*.h +.qmake.stash +*.pro.user +*.autosave +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc6cae3 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# VoiletXterm + +> 轻量的 SSH / Telnet / 串口终端工作台。Qt 5 + qmake,终端内核全部自研。 + +![screenshot](docs/screenshot.png) + +MobaXterm 很好用,但只跑 Windows、源码闭源。VoiletXterm 只做三件事——**SSH、Telnet、串口**——用一套自己写的 VT100/xterm 内核把它们塞进同一个窗口,三平台共用一套 C++ 代码。 + +## 特性 + +**终端内核(自研,不依赖任何终端控件)** +- VT100/xterm 转义序列:CSI / OSC / DCS、SGR 256 色与真彩色、滚动区、插入/删除行列、DEC 特殊字符集 +- 备用屏:`vim`、`htop`、`less` 正常工作;5000 行回滚 +- CJK 宽字符、组合字符、彩色 emoji、粗体/斜体/下划线/删除线/反色 +- 鼠标选区(单击拖动 / 双击选词 / 三击选行)、中键粘贴、自动复制 +- 完整键位编码:方向键(含 DECCKM)、F1–F12、Home/End、`Ctrl+字母`、`Alt` 前缀、xterm 修饰符 +- 中文输入法走 `inputMethodEvent`,候选窗跟随光标 + +**三种连接** + +| 类型 | 实现 | 说明 | +| --- | --- | --- | +| SSH | 系统 `ssh` 跑在伪终端(pty)上 | `~/.ssh/config`、密钥、agent、`ProxyJump`、known_hosts、二次验证全部照常生效 | +| Telnet | 内置 IAC 协商 | 自动回 `WILL/DO`,支持 NAWS 窗口尺寸随窗口变化 | +| 串口 | QSerialPort | 波特率 / 数据位 / 校验 / 停止位 / 流控 / 本地回显 | + +**界面** +- 多标签会话,标签标题跟随远端 `OSC 0/2` 标题 +- 会话保存与复用(QSettings 持久化),新建/编辑/删除 +- 命令行直达:`voiletxterm user@host:22` / `telnet://host:port` / `serial:/dev/ttyUSB0@115200` +- 深色紫罗兰主题,Ctrl+滚轮缩放字号 + +## 构建 + +需要 Qt 5.15(Widgets / Network / SerialPort 三个模块)。 + +```bash +export PATH=/path/to/Qt/5.15.2/gcc_64/bin:$PATH +mkdir -p build && cd build +qmake ../VoiletXterm.pro +make -j$(nproc) +./voiletxterm +``` + +串口需要读写权限,通常把用户加进 `dialout` 组:`sudo usermod -aG dialout $USER`。 + +## 使用 + +```bash +voiletxterm # 打开会话窗口 +voiletxterm root@10.0.0.5:2222 # 直接 SSH +voiletxterm telnet://192.168.1.1 # 直接 Telnet +voiletxterm serial:/dev/ttyUSB0@115200 +``` + +| 快捷键 | 作用 | +| --- | --- | +| `Ctrl+N` | 新建 SSH 会话 | +| `Ctrl+W` | 关闭当前标签 | +| `Ctrl+Shift+C` / `Ctrl+Shift+V` | 复制 / 粘贴 | +| `Ctrl++` / `Ctrl+-` / `Ctrl+0` | 放大 / 缩小 / 重置字号 | + +## 结构 + +``` +src/core/ Screen、Cell、VtParser、字符宽度、调色板 ← 只依赖 QtCore,可无头测试 +src/term/ TerminalWidget(QPainter 渲染)、主题 +src/session/ Transport 抽象、PtyProcess、SSH/Telnet/串口实现 +src/ui/ MainWindow、SessionDialog、会话存储、样式 +tests/ test_core(转义序列)、test_widget(离屏渲染 + 键位编码) +``` + +终端内核和界面完全解耦:`src/core` 只依赖 QtCore,测试可以直接跑在无显示环境里。 + +## 设计取舍 + +**为什么 SSH 用系统 `ssh` 而不是内嵌协议库?** +SSH 客户端最难的不是握手,而是把密钥、agent、跳板、known_hosts、二次验证这些琐事处理对。系统 OpenSSH 已经替我们做了二十年。VoiletXterm 用 `forkpty` 起一个 `ssh`,窗口尺寸通过 `TIOCSWINSZ` 同步,密码提示、主机指纹确认直接显示在终端里——所见即系统 ssh 的所见。 + +代价是依赖系统的 `ssh`(Linux/macOS 自带),以及无法做程序化的密码保管。 + +**为什么不用 qtermwidget?** +它是 GPL-2.0,一旦引入整个项目就得按 GPL 发布;而且 Konsole 血统的 QPainter 路径限制了性能上限。自研内核约 2000 行,MIT 无约束,问题也能自己修。 + +**宽字符对齐** +默认字体优先选半角/全角宽度严格 1:2 的字体(Sarasa Mono SC、文泉驿等宽正黑),这样 `中` 正好占两格;找不到再退回 DejaVu Sans Mono。 + +## 已知限制 + +- 只在 Linux 上验证过;`forkpty` 是 POSIX 接口,Windows 需要换成 ConPTY 才能编译 +- 没有 SFTP 文件面板、没有 X11 转发、没有 RDP(MobaXterm 的那部分不在当前范围) +- 没有标签内搜索、没有分屏 +- SSH 密码登录在终端里交互,不经过独立密码框 + +## 许可 + +MIT diff --git a/VoiletXterm.pro b/VoiletXterm.pro new file mode 100644 index 0000000..2801809 --- /dev/null +++ b/VoiletXterm.pro @@ -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 diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..6a527ee11306bb357433a8e6cb35e03704949f56 GIT binary patch literal 33431 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 +#include + +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 diff --git a/src/core/CharWidth.cpp b/src/core/CharWidth.cpp new file mode 100644 index 0000000..a46d3e9 --- /dev/null +++ b/src/core/CharWidth.cpp @@ -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 +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 diff --git a/src/core/CharWidth.h b/src/core/CharWidth.h new file mode 100644 index 0000000..939f884 --- /dev/null +++ b/src/core/CharWidth.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +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 diff --git a/src/core/Palette.cpp b/src/core/Palette.cpp new file mode 100644 index 0000000..4738ffc --- /dev/null +++ b/src/core/Palette.cpp @@ -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 diff --git a/src/core/Palette.h b/src/core/Palette.h new file mode 100644 index 0000000..83a2aad --- /dev/null +++ b/src/core/Palette.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +namespace vx { + +// Standard xterm color for palette index 0..255 (0xRRGGBB). +quint32 defaultPaletteColor(int index); + +} // namespace vx diff --git a/src/core/Screen.cpp b/src/core/Screen.cpp new file mode 100644 index 0000000..5d81ec0 --- /dev/null +++ b/src/core/Screen.cpp @@ -0,0 +1,897 @@ +#include "Screen.h" + +#include "CharWidth.h" + +#include +#include + +#include +#include + +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> &Screen::grid() +{ + return m_altActive ? m_alt : m_primary; +} + +const QVector> &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 &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 &&row) +{ + m_history.push_back(std::move(row)); + while (int(m_history.size()) > m_historyLimit) + m_history.pop_front(); +} + +const QVector &Screen::lineVector(int index) const +{ + static const QVector 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 &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(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 ¶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 diff --git a/src/core/Screen.h b/src/core/Screen.h new file mode 100644 index 0000000..51de832 --- /dev/null +++ b/src/core/Screen.h @@ -0,0 +1,169 @@ +#pragma once + +#include +#include +#include + +#include + +#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 &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 ¶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> &grid(); + const QVector> &grid() const; + Cell blankCell() const; + void clearRow(int row); + void clearCellAt(int row, int col); + void sanitizeWide(int row); + void pushHistory(QVector &&row); + void scrollUpRegion(int top, int bottom, int count); + void scrollDownRegion(int top, int bottom, int count); + static void resizeRow(QVector &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> m_primary; + QVector> m_alt; + bool m_altActive = false; + std::deque> 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 m_graphemes; +}; + +} // namespace vx diff --git a/src/core/VtParser.cpp b/src/core/VtParser.cpp new file mode 100644 index 0000000..f651d13 --- /dev/null +++ b/src/core/VtParser.cpp @@ -0,0 +1,493 @@ +#include "VtParser.h" + +#include "Screen.h" + +#include +#include +#include + +#include + +namespace vx { + +namespace { + +void parseParams(const QByteArray &buf, char &priv, QVector &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 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 diff --git a/src/core/VtParser.h b/src/core/VtParser.h new file mode 100644 index 0000000..dd8d68d --- /dev/null +++ b/src/core/VtParser.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +#include + +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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..9ae0bf1 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,88 @@ +#include "session/SessionSpec.h" +#include "ui/MainWindow.h" +#include "ui/Style.h" + +#include +#include + +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(); +} diff --git a/src/session/PtyProcess.cpp b/src/session/PtyProcess.cpp new file mode 100644 index 0000000..b83763e --- /dev/null +++ b/src/session/PtyProcess.cpp @@ -0,0 +1,224 @@ +#include "PtyProcess.h" + +#include + +#ifndef Q_OS_UNIX +#include +#endif + +#ifdef Q_OS_UNIX +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +namespace vx { + +PtyProcess::PtyProcess(QObject *parent) + : QObject(parent) +{ +} + +PtyProcess::~PtyProcess() +{ + stop(); +} + +bool PtyProcess::start(const QString &program, const QStringList &args, + const QHash &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 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 diff --git a/src/session/PtyProcess.h b/src/session/PtyProcess.h new file mode 100644 index 0000000..e123af6 --- /dev/null +++ b/src/session/PtyProcess.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +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 &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 diff --git a/src/session/SerialTransport.cpp b/src/session/SerialTransport.cpp new file mode 100644 index 0000000..464ae54 --- /dev/null +++ b/src/session/SerialTransport.cpp @@ -0,0 +1,98 @@ +#include "SerialTransport.h" + +#include + +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 diff --git a/src/session/SerialTransport.h b/src/session/SerialTransport.h new file mode 100644 index 0000000..71db386 --- /dev/null +++ b/src/session/SerialTransport.h @@ -0,0 +1,28 @@ +#pragma once + +#include "SessionSpec.h" +#include "Transport.h" + +#include + +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 diff --git a/src/session/SessionFactory.cpp b/src/session/SessionFactory.cpp new file mode 100644 index 0000000..85f140a --- /dev/null +++ b/src/session/SessionFactory.cpp @@ -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 diff --git a/src/session/SessionFactory.h b/src/session/SessionFactory.h new file mode 100644 index 0000000..4b4beb2 --- /dev/null +++ b/src/session/SessionFactory.h @@ -0,0 +1,11 @@ +#pragma once + +#include "SessionSpec.h" + +namespace vx { + +class Transport; + +Transport *createTransport(const SessionSpec &spec, QObject *parent); + +} // namespace vx diff --git a/src/session/SessionSpec.cpp b/src/session/SessionSpec.cpp new file mode 100644 index 0000000..65feed5 --- /dev/null +++ b/src/session/SessionSpec.cpp @@ -0,0 +1,80 @@ +#include "SessionSpec.h" + +#include + +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 diff --git a/src/session/SessionSpec.h b/src/session/SessionSpec.h new file mode 100644 index 0000000..54b0f57 --- /dev/null +++ b/src/session/SessionSpec.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/session/SshTransport.cpp b/src/session/SshTransport.cpp new file mode 100644 index 0000000..c318436 --- /dev/null +++ b/src/session/SshTransport.cpp @@ -0,0 +1,97 @@ +#include "SshTransport.h" + +#include "PtyProcess.h" + +#include +#include + +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 &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 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 diff --git a/src/session/SshTransport.h b/src/session/SshTransport.h new file mode 100644 index 0000000..7155685 --- /dev/null +++ b/src/session/SshTransport.h @@ -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 &env) const; + + SessionSpec m_spec; + QString m_description; + PtyProcess *m_pty = nullptr; +}; + +} // namespace vx diff --git a/src/session/TelnetTransport.cpp b/src/session/TelnetTransport.cpp new file mode 100644 index 0000000..0b81a23 --- /dev/null +++ b/src/session/TelnetTransport.cpp @@ -0,0 +1,197 @@ +#include "TelnetTransport.h" + +#include + +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(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(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 diff --git a/src/session/TelnetTransport.h b/src/session/TelnetTransport.h new file mode 100644 index 0000000..8cac8cf --- /dev/null +++ b/src/session/TelnetTransport.h @@ -0,0 +1,42 @@ +#pragma once + +#include "SessionSpec.h" +#include "Transport.h" + +#include + +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 m_negotiated; + QByteArray m_pendingOut; +}; + +} // namespace vx diff --git a/src/session/Transport.h b/src/session/Transport.h new file mode 100644 index 0000000..db4a4c0 --- /dev/null +++ b/src/session/Transport.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/term/TerminalTheme.cpp b/src/term/TerminalTheme.cpp new file mode 100644 index 0000000..cbea696 --- /dev/null +++ b/src/term/TerminalTheme.cpp @@ -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 diff --git a/src/term/TerminalTheme.h b/src/term/TerminalTheme.h new file mode 100644 index 0000000..0b2cd99 --- /dev/null +++ b/src/term/TerminalTheme.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace vx { + +struct TerminalTheme { + QColor background; + QColor foreground; + QColor cursor; + QColor cursorText; + QColor selection; + QColor ansi[16]; +}; + +TerminalTheme darkVioletTheme(); + +} // namespace vx diff --git a/src/term/TerminalWidget.cpp b/src/term/TerminalWidget.cpp new file mode 100644 index 0000000..0ab8ef6 --- /dev/null +++ b/src/term/TerminalWidget.cpp @@ -0,0 +1,1104 @@ +#include "TerminalWidget.h" + +#include "../core/Cell.h" +#include "../core/CharWidth.h" +#include "../core/Palette.h" +#include "../session/Transport.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace vx { + +namespace { + +// Preference order matters: the first two families have a 1:2 Latin/CJK advance +// ratio, so full width characters land exactly on two cells. +QString pickDefaultFontFamily() +{ + const QStringList available = QFontDatabase().families(); + const QStringList preferred = { + QStringLiteral("Sarasa Mono SC"), + QStringLiteral("WenQuanYi Zen Hei Mono"), + QStringLiteral("DejaVu Sans Mono"), + QStringLiteral("Liberation Mono"), + QStringLiteral("Noto Sans Mono"), + QStringLiteral("Consolas"), + QStringLiteral("Menlo"), + }; + for (const QString &family : preferred) { + if (available.contains(family, Qt::CaseInsensitive)) + return family; + } + const QFont systemFixed = QFontDatabase::systemFont(QFontDatabase::FixedFont); + return systemFixed.family().isEmpty() ? QStringLiteral("monospace") : systemFixed.family(); +} + +bool isWordChar(char32_t c) +{ + if (c > 0x7F) + return true; + if (c >= U'0' && c <= U'9') + return true; + if ((c >= U'a' && c <= U'z') || (c >= U'A' && c <= U'Z')) + return true; + switch (c) { + case U'_': case U'-': case U'.': case U'/': case U':': + case U'@': case U'~': case U'=': case U'+': case U'%': + return true; + default: + return false; + } +} + +} // namespace + +TerminalWidget::TerminalWidget(QWidget *parent) + : QAbstractScrollArea(parent) +{ + setFocusPolicy(Qt::StrongFocus); + setAttribute(Qt::WA_InputMethodEnabled, true); + setFrameShape(QFrame::NoFrame); + setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + viewport()->setCursor(Qt::IBeamCursor); + viewport()->setAutoFillBackground(false); + + m_theme = darkVioletTheme(); + for (int i = 0; i < 256; ++i) + m_palette[i] = QColor::fromRgb(QRgb(0xFF000000u | defaultPaletteColor(i))); + for (int i = 0; i < 16; ++i) + m_palette[i] = m_theme.ansi[i]; + + m_fontFamily = pickDefaultFontFamily(); + + m_screen = new Screen(this); + m_parser = new VtParser(m_screen); + m_screen->initialize(80, 24); + + connect(m_screen, &Screen::titleChanged, this, &TerminalWidget::titleChanged); + connect(m_screen, &Screen::workingDirectoryChanged, this, &TerminalWidget::workingDirectoryChanged); + connect(m_screen, &Screen::bell, this, &TerminalWidget::bell); + connect(m_screen, &Screen::response, this, [this](const QByteArray &data) { sendBytes(data); }); + connect(m_screen, &Screen::clipboardRequested, this, [this](const QString &text) { copyToClipboards(text); }); + + m_blinkTimer.setInterval(530); + connect(&m_blinkTimer, &QTimer::timeout, this, &TerminalWidget::onBlinkTimer); + m_blinkTimer.start(); + + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &TerminalWidget::onScrollBarMoved); + + updateFonts(); + updateScrollBar(); +} + +TerminalWidget::~TerminalWidget() = default; + +void TerminalWidget::setTransport(Transport *transport) +{ + if (m_transport == transport) + return; + releaseTransport(); + m_transport = transport; + if (!m_transport) + return; + + connect(m_transport, &Transport::dataReceived, this, &TerminalWidget::onDataReceived); + connect(m_transport, &Transport::finished, this, &TerminalWidget::onTransportFinished); + connect(m_transport, &Transport::connected, this, [this] { viewport()->update(); }); + + m_transport->start(m_screen->columns(), m_screen->rows()); +} + +void TerminalWidget::releaseTransport() +{ + if (!m_transport) + return; + m_transport->disconnect(this); + m_transport->deleteLater(); + m_transport = nullptr; +} + +void TerminalWidget::setTheme(const TerminalTheme &theme) +{ + m_theme = theme; + for (int i = 0; i < 256; ++i) + m_palette[i] = QColor::fromRgb(QRgb(0xFF000000u | defaultPaletteColor(i))); + for (int i = 0; i < 16; ++i) + m_palette[i] = m_theme.ansi[i]; + viewport()->update(); +} + +void TerminalWidget::setBaseFontFamily(const QString &family) +{ + if (family.isEmpty() || family == m_fontFamily) + return; + m_fontFamily = family; + updateFonts(); +} + +void TerminalWidget::setBaseFontSize(int points) +{ + points = std::clamp(points, 6, 48); + if (points == m_fontSize) + return; + m_fontSize = points; + updateFonts(); +} + +QSize TerminalWidget::sizeHint() const +{ + return QSize(int(m_cellWidth * 100), int(m_cellHeight * 28)); +} + +void TerminalWidget::updateFonts() +{ + m_fonts[0] = QFont(m_fontFamily, m_fontSize); + m_fonts[0].setStyleHint(QFont::Monospace); + m_fonts[0].setFixedPitch(true); + m_fonts[1] = m_fonts[0]; + m_fonts[1].setBold(true); + m_fonts[2] = m_fonts[0]; + m_fonts[2].setItalic(true); + m_fonts[3] = m_fonts[1]; + m_fonts[3].setItalic(true); + + const QFontMetricsF fm(m_fonts[0]); + m_cellWidth = std::max(1.0, fm.horizontalAdvance(QLatin1Char('M'))); + m_cellHeight = std::max(1.0, std::floor(fm.height() + 0.5)); + m_ascent = std::floor(fm.ascent() + 0.5); + + recomputeGeometry(); + viewport()->update(); +} + +void TerminalWidget::recomputeGeometry() +{ + if (!m_screen || !isVisible()) + return; + const int cols = int(viewport()->width() / m_cellWidth); + const int rows = int(viewport()->height() / m_cellHeight); + // Layout emits transient tiny sizes while a tab is being built; ignoring + // them keeps the scrollback and cursor stable. + if (cols < 2 || rows < 2) + return; + if (cols != m_screen->columns() || rows != m_screen->rows()) { + m_screen->resize(cols, rows); + if (m_transport) + m_transport->resizeTerminal(cols, rows); + } + updateScrollBar(); +} + +int TerminalWidget::visibleRows() const +{ + return std::max(1, int(viewport()->height() / m_cellHeight)); +} + +int TerminalWidget::visibleColumns() const +{ + return std::max(1, int(viewport()->width() / m_cellWidth)); +} + +int TerminalWidget::topLineIndex() const +{ + const int total = m_screen->totalLines(); + const int rows = visibleRows(); + return std::max(0, total - rows - m_scrollOffset); +} + +int TerminalWidget::historyVisible() const +{ + return m_screen->historyVisible(); +} + +void TerminalWidget::resizeEvent(QResizeEvent *event) +{ + QAbstractScrollArea::resizeEvent(event); + recomputeGeometry(); +} + +void TerminalWidget::showEvent(QShowEvent *event) +{ + QAbstractScrollArea::showEvent(event); + recomputeGeometry(); +} + +void TerminalWidget::onDataReceived(const QByteArray &data) +{ + m_parser->feed(data); + m_cursorOn = true; + if (m_scrollOffset != 0 && m_screen->alternateScreen()) + m_scrollOffset = 0; + updateScrollBar(); + viewport()->update(); +} + +void TerminalWidget::onTransportFinished(const QString &reason) +{ + emit sessionFinished(reason); +} + +void TerminalWidget::onBlinkTimer() +{ + if (!hasFocus()) + return; + m_cursorOn = !m_cursorOn; + viewport()->update(); +} + +void TerminalWidget::updateScrollBar() +{ + QScrollBar *sb = verticalScrollBar(); + if (!sb) + return; + const int hist = m_screen->alternateScreen() ? 0 : m_screen->historyCount(); + if (m_scrollOffset > hist) + m_scrollOffset = hist; + m_updatingScrollBar = true; + sb->setRange(0, hist); + sb->setPageStep(visibleRows()); + sb->setSingleStep(1); + sb->setValue(hist - m_scrollOffset); + m_updatingScrollBar = false; +} + +void TerminalWidget::onScrollBarMoved(int value) +{ + if (m_updatingScrollBar) + return; + const int hist = m_screen->alternateScreen() ? 0 : m_screen->historyCount(); + m_scrollOffset = hist - value; + viewport()->update(); +} + +void TerminalWidget::scrollTo(int offset) +{ + const int hist = m_screen->alternateScreen() ? 0 : m_screen->historyCount(); + m_scrollOffset = std::clamp(offset, 0, hist); + updateScrollBar(); + viewport()->update(); +} + +QString TerminalWidget::cellText(const Cell &cell) const +{ + if (cell.ch == 0) + return QStringLiteral(" "); + QString text = codePointToString(cell.ch); + if (cell.extra != 0) + text += m_screen->graphemeText(cell.extra); + return text; +} + +QColor TerminalWidget::colorFor(Color color, bool foreground) const +{ + if (isDefaultColor(color)) + return foreground ? m_theme.foreground : m_theme.background; + if (isPaletteColor(color)) { + const int index = paletteIndexOf(color); + if (index >= 0 && index < 256) + return m_palette[index]; + } + const quint32 value = rgbValueOf(color); + return QColor(int((value >> 16) & 0xFF), int((value >> 8) & 0xFF), int(value & 0xFF)); +} + +const QFont &TerminalWidget::fontFor(quint16 attr) const +{ + const bool bold = (attr & AttrBold) != 0; + const bool italic = (attr & AttrItalic) != 0; + if (bold && italic) + return m_fonts[3]; + if (bold) + return m_fonts[1]; + if (italic) + return m_fonts[2]; + return m_fonts[0]; +} + +void TerminalWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(viewport()); + painter.fillRect(viewport()->rect(), m_theme.background); + painter.setRenderHint(QPainter::TextAntialiasing, true); + + if (m_screen->alternateScreen() && m_scrollOffset != 0) + m_scrollOffset = 0; + + const int rows = visibleRows(); + const int top = topLineIndex(); + const int total = m_screen->totalLines(); + + for (int y = 0; y < rows; ++y) { + const int absoluteLine = top + y; + if (absoluteLine < 0 || absoluteLine >= total) + break; + drawRow(painter, y, absoluteLine); + } + + drawCursor(painter); + drawPreedit(painter); +} + +void TerminalWidget::drawRow(QPainter &painter, int screenRow, int absoluteLine) +{ + const QVector &cells = m_screen->lineVector(absoluteLine); + const int cols = std::min(m_screen->columns(), cells.size()); + if (cols <= 0) + return; + + const qreal y = screenRow * m_cellHeight; + const qreal baseline = y + m_ascent; + + int c = 0; + while (c < cols) { + const Cell &cell = cells[c]; + if (cell.isWideTail()) { + ++c; + continue; + } + + const quint16 attrs = quint16(cell.attr & ~(AttrWide | AttrWideTail)); + QColor fg = colorFor(cell.fg, true); + QColor bg = colorFor(cell.bg, false); + const bool selected = isSelected(absoluteLine, c); + if (selected) + bg = m_theme.selection; + if (attrs & AttrReverse) + std::swap(fg, bg); + if (m_screen->reverseVideo()) + std::swap(fg, bg); + if (attrs & AttrHidden) + fg = bg; + if ((attrs & AttrBold) && isPaletteColor(cell.fg)) { + const int index = paletteIndexOf(cell.fg); + if (index < 8) + fg = m_palette[index + 8]; + } + if (attrs & AttrDim) + fg = fg.darker(150); + + const bool wide = cell.isWideHead(); + const int span = wide ? 2 : 1; + + if (bg != m_theme.background || selected) + painter.fillRect(QRectF(c * m_cellWidth, y, span * m_cellWidth, m_cellHeight), bg); + + if (wide) { + painter.save(); + painter.setClipRect(QRectF(c * m_cellWidth, y, 2 * m_cellWidth, m_cellHeight)); + painter.setFont(fontFor(attrs)); + painter.setPen(fg); + painter.drawText(QPointF(c * m_cellWidth, baseline), cellText(cell)); + painter.restore(); + ++c; + continue; + } + + int runStart = c; + QString run; + while (c < cols) { + const Cell &next = cells[c]; + if (next.isWideHead() || next.isWideTail()) + break; + if (next.fg != cell.fg || next.bg != cell.bg) + break; + if (quint16(next.attr & ~(AttrWide | AttrWideTail)) != attrs) + break; + if (isSelected(absoluteLine, c) != selected) + break; + run += cellText(next); + ++c; + } + if (c == runStart) + ++c; + + painter.setFont(fontFor(attrs)); + painter.setPen(fg); + painter.drawText(QPointF(runStart * m_cellWidth, baseline), run); + + const qreal runWidth = (c - runStart) * m_cellWidth; + if (attrs & (AttrUnderline | AttrDoubleUnderline)) { + painter.fillRect(QRectF(runStart * m_cellWidth, baseline + 1, runWidth, 1), fg); + if (attrs & AttrDoubleUnderline) + painter.fillRect(QRectF(runStart * m_cellWidth, baseline + 3, runWidth, 1), fg); + } + if (attrs & AttrStrike) + painter.fillRect(QRectF(runStart * m_cellWidth, y + m_cellHeight * 0.5, runWidth, 1), fg); + if (attrs & AttrOverline) + painter.fillRect(QRectF(runStart * m_cellWidth, y, runWidth, 1), fg); + } +} + +void TerminalWidget::drawCursor(QPainter &painter) +{ + if (!m_screen->cursorVisible()) + return; + if (hasFocus() && !m_cursorOn) + return; + + const int rows = visibleRows(); + const int top = topLineIndex(); + const int absolute = historyVisible() + m_screen->cursorRow(); + const int screenRow = absolute - top; + if (screenRow < 0 || screenRow >= rows) + return; + + const qreal x = m_screen->cursorCol() * m_cellWidth; + const qreal y = screenRow * m_cellHeight; + const QRectF rect(x, y, m_cellWidth, m_cellHeight); + + if (!hasFocus()) { + painter.setPen(QPen(m_theme.cursor, 1)); + painter.drawRect(rect.adjusted(0.5, 0.5, -0.5, -0.5)); + return; + } + + painter.fillRect(rect, m_theme.cursor); + const QVector &cells = m_screen->lineVector(absolute); + const int col = m_screen->cursorCol(); + if (col >= 0 && col < cells.size()) { + const Cell &cell = cells[col]; + painter.setFont(fontFor(cell.attr)); + painter.setPen(m_theme.cursorText); + painter.drawText(QPointF(x, y + m_ascent), cellText(cell)); + } +} + +void TerminalWidget::drawPreedit(QPainter &painter) +{ + if (m_preedit.isEmpty()) + return; + const int rows = visibleRows(); + const int top = topLineIndex(); + const int absolute = historyVisible() + m_screen->cursorRow(); + const int screenRow = absolute - top; + if (screenRow < 0 || screenRow >= rows) + return; + + const qreal x = m_screen->cursorCol() * m_cellWidth; + const qreal y = screenRow * m_cellHeight; + painter.setFont(m_fonts[0]); + painter.setPen(m_theme.foreground); + painter.drawText(QPointF(x, y + m_ascent), m_preedit); + const qreal width = QFontMetricsF(m_fonts[0]).horizontalAdvance(m_preedit); + painter.fillRect(QRectF(x, y + m_ascent + 1, width, 1), m_theme.cursor); +} + +// ---------------------------------------------------------------- selection + +bool TerminalWidget::hasSelection() const +{ + return m_hasSelection && (m_selStartLine != m_selEndLine || m_selStartCol != m_selEndCol); +} + +void TerminalWidget::clearSelection() +{ + m_hasSelection = false; + m_selecting = false; + viewport()->update(); +} + +void TerminalWidget::setSelection(int startLine, int startCol, int endLine, int endCol) +{ + if (startLine > endLine || (startLine == endLine && startCol > endCol)) { + std::swap(startLine, endLine); + std::swap(startCol, endCol); + } + m_selStartLine = startLine; + m_selStartCol = startCol; + m_selEndLine = endLine; + m_selEndCol = endCol; + m_hasSelection = true; + viewport()->update(); +} + +void TerminalWidget::selectionRange(int &startLine, int &startCol, int &endLine, int &endCol) const +{ + startLine = m_selStartLine; + startCol = m_selStartCol; + endLine = m_selEndLine; + endCol = m_selEndCol; + if (startLine > endLine || (startLine == endLine && startCol > endCol)) { + std::swap(startLine, endLine); + std::swap(startCol, endCol); + } +} + +bool TerminalWidget::isSelected(int absoluteLine, int column) const +{ + if (!m_hasSelection) + return false; + int sl, sc, el, ec; + selectionRange(sl, sc, el, ec); + if (absoluteLine < sl || absoluteLine > el) + return false; + if (sl == el) + return column >= sc && column <= ec; + if (absoluteLine == sl) + return column >= sc; + if (absoluteLine == el) + return column <= ec; + return true; +} + +QString TerminalWidget::selectedText() const +{ + if (!m_hasSelection) + return QString(); + int sl, sc, el, ec; + selectionRange(sl, sc, el, ec); + QString out; + for (int line = sl; line <= el; ++line) { + const QVector &cells = m_screen->lineVector(line); + const int from = (line == sl) ? sc : 0; + const int to = (line == el) ? std::min(ec, cells.size() - 1) : cells.size() - 1; + QString row; + for (int c = from; c <= to && c < cells.size(); ++c) { + const Cell &cell = cells[c]; + if (cell.isWideTail()) + continue; + row += cellText(cell); + } + while (row.endsWith(QLatin1Char(' '))) + row.chop(1); + out += row; + if (line < el) + out += QLatin1Char('\n'); + } + return out; +} + +void TerminalWidget::copySelection() +{ + const QString text = selectedText(); + if (!text.isEmpty()) + copyToClipboards(text); +} + +void TerminalWidget::copyToClipboards(const QString &text) +{ + if (text.isEmpty()) + return; + QClipboard *clipboard = QApplication::clipboard(); + clipboard->setText(text, QClipboard::Clipboard); + if (clipboard->supportsSelection()) + clipboard->setText(text, QClipboard::Selection); +} + +void TerminalWidget::selectAll() +{ + const int total = m_screen->totalLines(); + if (total <= 0) + return; + setSelection(0, 0, total - 1, std::max(0, m_screen->columns() - 1)); +} + +void TerminalWidget::pasteClipboard() +{ + QString text = QApplication::clipboard()->text(); + if (text.isEmpty()) + return; + text.replace(QStringLiteral("\r\n"), QStringLiteral("\r")); + text.replace(QLatin1Char('\n'), QLatin1Char('\r')); + if (m_screen->bracketedPaste()) + text = QStringLiteral("\033[200~") + text + QStringLiteral("\033[201~"); + sendText(text); + scrollTo(0); +} + +void TerminalWidget::clearScrollback() +{ + m_screen->clearScrollback(); + scrollTo(0); + updateScrollBar(); + viewport()->update(); +} + +void TerminalWidget::clearScreen() +{ + m_screen->eraseInDisplay(2); + m_screen->moveTo(0, 0); + viewport()->update(); +} + +void TerminalWidget::zoomIn() +{ + setBaseFontSize(m_fontSize + 1); +} + +void TerminalWidget::zoomOut() +{ + setBaseFontSize(m_fontSize - 1); +} + +void TerminalWidget::resetZoom() +{ + setBaseFontSize(11); +} + +// ------------------------------------------------------------------- mouse + +QPoint TerminalWidget::cellAt(const QPoint &pos) const +{ + const int col = std::clamp(int(pos.x() / m_cellWidth), 0, std::max(0, m_screen->columns() - 1)); + int line = topLineIndex() + int(pos.y() / m_cellHeight); + line = std::clamp(line, 0, std::max(0, m_screen->totalLines() - 1)); + return QPoint(col, line); +} + +int TerminalWidget::absoluteLineAt(int y) const +{ + int line = topLineIndex() + int(y / m_cellHeight); + return std::clamp(line, 0, std::max(0, m_screen->totalLines() - 1)); +} + +void TerminalWidget::startSelection(const QPoint &pos, int mode) +{ + const QPoint cell = cellAt(pos); + m_selectMode = mode; + m_selecting = true; + setSelection(cell.y(), cell.x(), cell.y(), cell.x()); +} + +void TerminalWidget::extendSelection(const QPoint &pos) +{ + const QPoint cell = cellAt(pos); + if (m_selectMode == 2) { + setSelection(m_selStartLine, 0, cell.y(), std::max(0, m_screen->columns() - 1)); + return; + } + if (m_selectMode == 1) { + const QVector &cells = m_screen->lineVector(cell.y()); + int from = cell.x(); + int to = cell.x(); + while (from > 0 && from - 1 < cells.size() && isWordChar(cells[from - 1].ch)) + --from; + while (to + 1 < cells.size() && isWordChar(cells[to + 1].ch)) + ++to; + setSelection(cell.y(), from, cell.y(), to); + return; + } + setSelection(m_selStartLine, m_selStartCol, cell.y(), cell.x()); +} + +void TerminalWidget::selectWordAt(const QPoint &pos) +{ + const QPoint cell = cellAt(pos); + const QVector &cells = m_screen->lineVector(cell.y()); + if (cell.x() >= cells.size()) + return; + int from = cell.x(); + int to = cell.x(); + if (!isWordChar(cells[from].ch)) { + setSelection(cell.y(), from, cell.y(), to); + return; + } + while (from > 0 && isWordChar(cells[from - 1].ch)) + --from; + while (to + 1 < cells.size() && isWordChar(cells[to + 1].ch)) + ++to; + setSelection(cell.y(), from, cell.y(), to); +} + +void TerminalWidget::selectLineAt(const QPoint &pos) +{ + const int line = cellAt(pos).y(); + setSelection(line, 0, line, std::max(0, m_screen->columns() - 1)); +} + +void TerminalWidget::mousePressEvent(QMouseEvent *event) +{ + setFocus(); + if (event->button() == Qt::MiddleButton) { + const QString text = QApplication::clipboard()->text(QClipboard::Selection); + if (!text.isEmpty()) { + QString filtered = text; + filtered.replace(QStringLiteral("\r\n"), QStringLiteral("\r")); + filtered.replace(QLatin1Char('\n'), QLatin1Char('\r')); + sendText(filtered); + } + return; + } + if (event->button() != Qt::LeftButton) { + QAbstractScrollArea::mousePressEvent(event); + return; + } + + if (m_clickTimer.isValid() && m_clickTimer.elapsed() < QApplication::doubleClickInterval()) + ++m_clickCount; + else + m_clickCount = 1; + m_clickTimer.restart(); + + if (m_clickCount >= 3) { + selectLineAt(event->pos()); + m_selecting = true; + m_selectMode = 2; + return; + } + if (m_clickCount == 2) { + selectWordAt(event->pos()); + m_selecting = true; + m_selectMode = 1; + return; + } + + if (event->modifiers() & Qt::ShiftModifier && m_hasSelection) { + extendSelection(event->pos()); + m_selecting = true; + return; + } + clearSelection(); + startSelection(event->pos(), 0); +} + +void TerminalWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_selecting) + return; + + const int y = event->pos().y(); + if (y < 0) { + scrollTo(m_scrollOffset + 1); + } else if (y > viewport()->height()) { + scrollTo(m_scrollOffset - 1); + } + extendSelection(event->pos()); +} + +void TerminalWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton && m_selecting) { + m_selecting = false; + if (hasSelection()) + copySelection(); + } +} + +void TerminalWidget::wheelEvent(QWheelEvent *event) +{ + if (event->modifiers() & Qt::ControlModifier) { + const int delta = event->angleDelta().y(); + if (delta > 0) + zoomIn(); + else if (delta < 0) + zoomOut(); + event->accept(); + return; + } + const int steps = event->angleDelta().y() / 40; + if (steps != 0) + scrollTo(m_scrollOffset + steps); + event->accept(); +} + +void TerminalWidget::contextMenuEvent(QContextMenuEvent *event) +{ + QMenu menu(this); + QAction *copyAction = menu.addAction(tr("复制")); + copyAction->setEnabled(hasSelection()); + QAction *pasteAction = menu.addAction(tr("粘贴")); + pasteAction->setEnabled(m_transport != nullptr); + menu.addSeparator(); + QAction *selectAllAction = menu.addAction(tr("全选")); + QAction *clearAction = menu.addAction(tr("清空回滚缓冲区")); + + QAction *chosen = menu.exec(event->globalPos()); + if (chosen == copyAction) + copySelection(); + else if (chosen == pasteAction) + pasteClipboard(); + else if (chosen == selectAllAction) + selectAll(); + else if (chosen == clearAction) + clearScrollback(); +} + +// ----------------------------------------------------------------- keyboard + +void TerminalWidget::sendText(const QString &text) +{ + if (text.isEmpty()) + return; + sendBytes(text.toUtf8()); +} + +void TerminalWidget::sendBytes(const QByteArray &bytes) +{ + if (m_transport) + m_transport->write(bytes); + scrollTo(0); +} + +void TerminalWidget::keyPressEvent(QKeyEvent *event) +{ + const Qt::KeyboardModifiers mods = event->modifiers(); + const bool ctrl = mods.testFlag(Qt::ControlModifier); + const bool shift = mods.testFlag(Qt::ShiftModifier); + const bool alt = mods.testFlag(Qt::AltModifier); + + if (ctrl && shift) { + switch (event->key()) { + case Qt::Key_C: + copySelection(); + return; + case Qt::Key_V: + pasteClipboard(); + return; + case Qt::Key_A: + selectAll(); + return; + default: + break; + } + } + if (ctrl && event->key() == Qt::Key_Insert) { + copySelection(); + return; + } + if (shift && event->key() == Qt::Key_Insert) { + pasteClipboard(); + return; + } + if (ctrl && shift && event->key() == Qt::Key_F) { + return; + } + Q_UNUSED(alt); + sendKey(event); +} + +void TerminalWidget::sendKey(QKeyEvent *event) +{ + const int key = event->key(); + const Qt::KeyboardModifiers mods = event->modifiers(); + const bool ctrl = mods.testFlag(Qt::ControlModifier); + const bool alt = mods.testFlag(Qt::AltModifier); + const bool shift = mods.testFlag(Qt::ShiftModifier); + + int modCode = 0; + if (shift) + modCode |= 1; + if (alt) + modCode |= 2; + if (ctrl) + modCode |= 4; + if (modCode != 0) + modCode += 1; + + const auto arrow = [&](char finalByte) -> QByteArray { + if (modCode != 0) + return QByteArray("\033[1;") + QByteArray::number(modCode) + finalByte; + if (m_screen->applicationCursorKeys()) + return QByteArray("\033O") + finalByte; + return QByteArray("\033[") + finalByte; + }; + const auto tilde = [&](int code) -> QByteArray { + QByteArray out = "\033["; + out += QByteArray::number(code); + if (modCode != 0) { + out += ';'; + out += QByteArray::number(modCode); + } + out += '~'; + return out; + }; + const auto ss3 = [&](char finalByte) -> QByteArray { + if (modCode != 0) + return QByteArray("\033[1;") + QByteArray::number(modCode) + finalByte; + return QByteArray("\033O") + finalByte; + }; + + switch (key) { + case Qt::Key_Return: + case Qt::Key_Enter: + sendBytes(alt ? QByteArray("\033\r") : QByteArray("\r")); + return; + case Qt::Key_Backspace: + sendBytes(alt ? QByteArray("\033\177") : QByteArray("\177")); + return; + case Qt::Key_Tab: + if (shift) + sendBytes("\033[Z"); + else + sendBytes("\t"); + return; + case Qt::Key_Backtab: + sendBytes("\033[Z"); + return; + case Qt::Key_Escape: + sendBytes("\033"); + return; + case Qt::Key_Up: + sendBytes(arrow('A')); + return; + case Qt::Key_Down: + sendBytes(arrow('B')); + return; + case Qt::Key_Right: + sendBytes(arrow('C')); + return; + case Qt::Key_Left: + sendBytes(arrow('D')); + return; + case Qt::Key_Home: + sendBytes(modCode ? QByteArray("\033[1;") + QByteArray::number(modCode) + "H" : QByteArray("\033[H")); + return; + case Qt::Key_End: + sendBytes(modCode ? QByteArray("\033[1;") + QByteArray::number(modCode) + "F" : QByteArray("\033[F")); + return; + case Qt::Key_Insert: + sendBytes(tilde(2)); + return; + case Qt::Key_Delete: + sendBytes(tilde(3)); + return; + case Qt::Key_PageUp: + sendBytes(tilde(5)); + return; + case Qt::Key_PageDown: + sendBytes(tilde(6)); + return; + case Qt::Key_F1: + sendBytes(ss3('P')); + return; + case Qt::Key_F2: + sendBytes(ss3('Q')); + return; + case Qt::Key_F3: + sendBytes(ss3('R')); + return; + case Qt::Key_F4: + sendBytes(ss3('S')); + return; + case Qt::Key_F5: + sendBytes(tilde(15)); + return; + case Qt::Key_F6: + sendBytes(tilde(17)); + return; + case Qt::Key_F7: + sendBytes(tilde(18)); + return; + case Qt::Key_F8: + sendBytes(tilde(19)); + return; + case Qt::Key_F9: + sendBytes(tilde(20)); + return; + case Qt::Key_F10: + sendBytes(tilde(21)); + return; + case Qt::Key_F11: + sendBytes(tilde(23)); + return; + case Qt::Key_F12: + sendBytes(tilde(24)); + return; + default: + break; + } + + if (ctrl) { + if (key >= Qt::Key_A && key <= Qt::Key_Z) { + const char code = char(key - Qt::Key_A + 1); + QByteArray out(1, code); + sendBytes(alt ? QByteArray("\033") + out : out); + return; + } + if (key == Qt::Key_Space || key == Qt::Key_At) { + sendBytes(alt ? QByteArray("\033\0", 3) : QByteArray(1, '\0')); + return; + } + switch (key) { + case Qt::Key_BracketLeft: + sendBytes("\033"); + return; + case Qt::Key_Backslash: + sendBytes("\034"); + return; + case Qt::Key_BracketRight: + sendBytes("\035"); + return; + case Qt::Key_AsciiCircum: + case Qt::Key_Underscore: + sendBytes("\036"); + return; + case Qt::Key_Slash: + sendBytes("\037"); + return; + default: + break; + } + } + + const QString text = event->text(); + if (!text.isEmpty() && !ctrl) { + if (alt) + sendText(QString(QChar(0x1B)) + text); + else + sendText(text); + return; + } + + QAbstractScrollArea::keyPressEvent(event); +} + +void TerminalWidget::inputMethodEvent(QInputMethodEvent *event) +{ + if (!event->commitString().isEmpty()) { + m_preedit.clear(); + sendText(event->commitString()); + } else { + m_preedit = event->preeditString(); + } + viewport()->update(); + event->accept(); +} + +QVariant TerminalWidget::inputMethodQuery(Qt::InputMethodQuery query) const +{ + if (query == Qt::ImEnabled) + return QVariant(true); + if (query == Qt::ImCursorRectangle) { + const int screenRow = historyVisible() + m_screen->cursorRow() - topLineIndex(); + return QRectF(m_screen->cursorCol() * m_cellWidth, screenRow * m_cellHeight, + m_cellWidth, m_cellHeight); + } + if (query == Qt::ImFont) + return m_fonts[0]; + if (query == Qt::ImCursorPosition) + return 0; + if (query == Qt::ImSurroundingText) + return QString(); + return QAbstractScrollArea::inputMethodQuery(query); +} + +void TerminalWidget::focusInEvent(QFocusEvent *event) +{ + QAbstractScrollArea::focusInEvent(event); + m_cursorOn = true; + viewport()->update(); +} + +void TerminalWidget::focusOutEvent(QFocusEvent *event) +{ + QAbstractScrollArea::focusOutEvent(event); + m_cursorOn = true; + viewport()->update(); +} + +} // namespace vx diff --git a/src/term/TerminalWidget.h b/src/term/TerminalWidget.h new file mode 100644 index 0000000..f635aac --- /dev/null +++ b/src/term/TerminalWidget.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#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 diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp new file mode 100644 index 0000000..2ea9550 --- /dev/null +++ b/src/ui/MainWindow.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(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("

VoiletXterm

" + "

一个轻量的 SSH / Telnet / 串口终端工作台。

" + "

Qt %1 · 自研 VT100/xterm 终端内核

") + .arg(QString::fromLatin1(qVersion()))); +} + +void MainWindow::closeEvent(QCloseEvent *event) +{ + QSettings settings; + settings.setValue(QStringLiteral("window/geometry"), saveGeometry()); + event->accept(); +} + +} // namespace vx diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h new file mode 100644 index 0000000..6346baf --- /dev/null +++ b/src/ui/MainWindow.h @@ -0,0 +1,61 @@ +#pragma once + +#include "../session/SessionSpec.h" + +#include + +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 diff --git a/src/ui/SessionDialog.cpp b/src/ui/SessionDialog.cpp new file mode 100644 index 0000000..239a6e3 --- /dev/null +++ b/src/ui/SessionDialog.cpp @@ -0,0 +1,365 @@ +#include "SessionDialog.h" + +#include "SessionStore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/ui/SessionDialog.h b/src/ui/SessionDialog.h new file mode 100644 index 0000000..a286a64 --- /dev/null +++ b/src/ui/SessionDialog.h @@ -0,0 +1,75 @@ +#pragma once + +#include "../session/SessionSpec.h" + +#include + +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 diff --git a/src/ui/SessionStore.cpp b/src/ui/SessionStore.cpp new file mode 100644 index 0000000..a876c8d --- /dev/null +++ b/src/ui/SessionStore.cpp @@ -0,0 +1,76 @@ +#include "SessionStore.h" + +#include +#include + +namespace vx { + +namespace { +const char *kArrayKey = "sessions"; +} + +QVector SessionStore::load() +{ + QVector 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 &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 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 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 diff --git a/src/ui/SessionStore.h b/src/ui/SessionStore.h new file mode 100644 index 0000000..2e7636b --- /dev/null +++ b/src/ui/SessionStore.h @@ -0,0 +1,18 @@ +#pragma once + +#include "../session/SessionSpec.h" + +#include + +namespace vx { + +class SessionStore +{ +public: + static QVector load(); + static void saveAll(const QVector &sessions); + static void upsert(const SessionSpec &spec); + static void removeByName(const QString &name); +}; + +} // namespace vx diff --git a/src/ui/Style.cpp b/src/ui/Style.cpp new file mode 100644 index 0000000..9634f6b --- /dev/null +++ b/src/ui/Style.cpp @@ -0,0 +1,79 @@ +#include "Style.h" + +#include +#include +#include + +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 diff --git a/src/ui/Style.h b/src/ui/Style.h new file mode 100644 index 0000000..02d16e1 --- /dev/null +++ b/src/ui/Style.h @@ -0,0 +1,9 @@ +#pragma once + +class QApplication; + +namespace vx { + +void applyDarkStyle(QApplication &app); + +} // namespace vx diff --git a/tests/test_core.cpp b/tests/test_core.cpp new file mode 100644 index 0000000..a24daba --- /dev/null +++ b/tests/test_core.cpp @@ -0,0 +1,231 @@ +// Minimal self checks for the terminal core (no GUI required). +#include +#include +#include + +#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; +} diff --git a/tests/test_widget.cpp b/tests/test_widget.cpp new file mode 100644 index 0000000..b17b9cb --- /dev/null +++ b/tests/test_widget.cpp @@ -0,0 +1,186 @@ +// Renders a TerminalWidget offscreen and inspects the produced pixels, so the +// painting path can be verified without a display. +#include +#include +#include +#include +#include + +#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; +} diff --git a/tests/tests.pro b/tests/tests.pro new file mode 100644 index 0000000..36463a9 --- /dev/null +++ b/tests/tests.pro @@ -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 diff --git a/tests/widgettest.pro b/tests/widgettest.pro new file mode 100644 index 0000000..0d6712f --- /dev/null +++ b/tests/widgettest.pro @@ -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