Files
liyepan 580abbcadf 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
2026-10-23 17:38:16 +08:00

187 lines
6.3 KiB
C++

// Renders a TerminalWidget offscreen and inspects the produced pixels, so the
// painting path can be verified without a display.
#include <QApplication>
#include <QImage>
#include <QScrollBar>
#include <QtTest/QTest>
#include <cstdio>
#include "session/Transport.h"
#include "core/Screen.h"
#include "core/VtParser.h"
#include "term/TerminalTheme.h"
#include "term/TerminalWidget.h"
using namespace vx;
static int g_failures = 0;
static void check(bool ok, const char *what, int line)
{
if (!ok) {
std::printf("FAIL %s:%d %s\n", __FILE__, line, what);
++g_failures;
}
}
#define CHECK(cond) check((cond), #cond, __LINE__)
class RecordingTransport : public Transport
{
public:
QByteArray written;
bool start(int, int) override { return true; }
void write(const QByteArray &data) override { written += data; }
QString description() const override { return QStringLiteral("recording"); }
};
static bool nearColor(const QColor &a, const QColor &b, int tolerance = 24)
{
return qAbs(a.red() - b.red()) <= tolerance && qAbs(a.green() - b.green()) <= tolerance
&& qAbs(a.blue() - b.blue()) <= tolerance;
}
static int countNear(const QImage &image, const QRect &rect, const QColor &color, int tolerance = 24)
{
int count = 0;
const QRect r = rect.intersected(image.rect());
for (int y = r.top(); y <= r.bottom(); ++y) {
for (int x = r.left(); x <= r.right(); ++x) {
if (nearColor(image.pixelColor(x, y), color, tolerance))
++count;
}
}
return count;
}
static int countNonBackground(const QImage &image, const QRect &rect, const QColor &bg)
{
int count = 0;
const QRect r = rect.intersected(image.rect());
for (int y = r.top(); y <= r.bottom(); ++y) {
for (int x = r.left(); x <= r.right(); ++x) {
if (!nearColor(image.pixelColor(x, y), bg, 6))
++count;
}
}
return count;
}
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
TerminalWidget widget;
widget.resize(820, 420);
widget.show();
QCoreApplication::processEvents();
const TerminalTheme theme = widget.theme();
const int cols = widget.screen()->columns();
const int rows = widget.screen()->rows();
CHECK(cols > 40);
CHECK(rows > 10);
const qreal cellW = qreal(widget.viewport()->width()) / cols;
const qreal cellH = qreal(widget.viewport()->height()) / rows;
CHECK(cellW > 4 && cellH > 8);
const auto cellRect = [&](int row, int col, int span = 1) {
return QRect(int(col * cellW), int(row * cellH), int(span * cellW), int(cellH));
};
// --- empty screen is uniformly background ---
QImage blank = widget.grab().toImage();
CHECK(blank.width() > 0);
// The home position shows a block cursor, so start checking below it.
QRect belowCursor(0, int(cellH), widget.viewport()->width(),
widget.viewport()->height() - int(cellH));
CHECK(countNonBackground(blank, belowCursor, theme.background) == 0);
// --- plain text draws ink ---
widget.parser()->feed("hello world");
QCoreApplication::processEvents();
QImage textImage = widget.grab().toImage();
CHECK(countNonBackground(textImage, textImage.rect(), theme.background) > 200);
// --- SGR color lands on the right cell ---
widget.parser()->feed("\r\n\033[31mR\033[0m");
QCoreApplication::processEvents();
QImage colorImage = widget.grab().toImage();
CHECK(countNear(colorImage, cellRect(1, 0), theme.ansi[1]) > 5);
// --- wide CJK glyph occupies two cells ---
widget.parser()->feed("\r\n\346\226\207"); // 文
QCoreApplication::processEvents();
QImage cjkImage = widget.grab().toImage();
CHECK(countNonBackground(cjkImage, cellRect(2, 0), theme.background) > 10);
CHECK(countNonBackground(cjkImage, cellRect(2, 1), theme.background) > 10);
// --- reverse video paints the cell background ---
widget.parser()->feed("\r\n\033[7mZ\033[0m");
QCoreApplication::processEvents();
QImage reverseImage = widget.grab().toImage();
CHECK(countNear(reverseImage, cellRect(3, 0), theme.foreground, 40) > 40);
// --- the cursor is painted where the shell left it ---
QImage cursorImage = widget.grab().toImage();
CHECK(countNear(cursorImage, cursorImage.rect(), theme.cursor, 30) > 30);
// --- selection uses the highlight color ---
widget.selectAll();
QCoreApplication::processEvents();
QImage selectionImage = widget.grab().toImage();
CHECK(countNear(selectionImage, selectionImage.rect(), theme.selection, 30) > 500);
// --- keyboard input is encoded the way a shell expects ---
{
RecordingTransport *recorder = new RecordingTransport;
widget.setTransport(recorder);
QTest::keyClicks(&widget, "hi");
CHECK(recorder->written == QByteArray("hi"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_Return);
CHECK(recorder->written == QByteArray("\r"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_Backspace);
CHECK(recorder->written == QByteArray("\x7f"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_Up);
CHECK(recorder->written == QByteArray("\033[A"));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_C, Qt::ControlModifier);
CHECK(recorder->written == QByteArray(1, '\x03'));
recorder->written.clear();
QTest::keyClick(&widget, Qt::Key_A, Qt::AltModifier);
CHECK(recorder->written == QByteArray("\033a", 2));
recorder->written.clear();
widget.screen()->setPrivateMode(1, true); // application cursor keys
QTest::keyClick(&widget, Qt::Key_Left);
CHECK(recorder->written == QByteArray("\033OD", 3));
recorder->written.clear();
widget.releaseTransport();
}
// --- scrolling fills the scrollbar range ---
widget.screen()->clearScrollback();
for (int i = 0; i < 80; ++i)
widget.parser()->feed("line\r\n");
QCoreApplication::processEvents();
CHECK(widget.screen()->historyCount() > 0);
if (g_failures == 0)
std::printf("widget render checks passed (%dx%d cells, %.1fx%.1f px)\n", cols, rows,
double(cellW), double(cellH));
else
std::printf("%d widget check(s) failed\n", g_failures);
return g_failures == 0 ? 0 : 1;
}