Add ANSI color rendering and terminal themes

This commit is contained in:
Keith Smith
2026-03-01 10:08:04 -07:00
parent 2b4f498259
commit 20ee48db32
4 changed files with 444 additions and 11 deletions

View File

@@ -4,6 +4,7 @@
#include "terminal_view.h" #include "terminal_view.h"
#include <QClipboard> #include <QClipboard>
#include <QComboBox>
#include <QDateTime> #include <QDateTime>
#include <QFileDialog> #include <QFileDialog>
#include <QFileInfo> #include <QFileInfo>
@@ -16,7 +17,6 @@
#include <QMessageBox> #include <QMessageBox>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QPushButton> #include <QPushButton>
#include <QTextCursor>
#include <QThread> #include <QThread>
#include <QToolButton> #include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -38,6 +38,7 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_reconnectButton(nullptr), m_reconnectButton(nullptr),
m_copyErrorButton(nullptr), m_copyErrorButton(nullptr),
m_clearTerminalButton(nullptr), m_clearTerminalButton(nullptr),
m_themeSelector(nullptr),
m_toggleDetailsButton(nullptr), m_toggleDetailsButton(nullptr),
m_toggleEventsButton(nullptr), m_toggleEventsButton(nullptr),
m_detailsPanel(nullptr), m_detailsPanel(nullptr),
@@ -196,11 +197,7 @@ void SessionTab::onBackendOutputReceived(const QString& text)
return; return;
} }
QTextCursor cursor = m_terminalOutput->textCursor(); m_terminalOutput->appendTerminalData(text);
cursor.movePosition(QTextCursor::End);
cursor.insertText(text);
m_terminalOutput->setTextCursor(cursor);
m_terminalOutput->ensureCursorVisible();
} }
void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt) void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
@@ -268,13 +265,17 @@ void SessionTab::setupUi()
auto* terminalHeader = new QHBoxLayout(); auto* terminalHeader = new QHBoxLayout();
auto* terminalLabel = new QLabel(QStringLiteral("SSH Terminal"), this); auto* terminalLabel = new QLabel(QStringLiteral("SSH Terminal"), this);
m_themeSelector = new QComboBox(this);
m_themeSelector->addItems(TerminalView::themeNames());
m_themeSelector->setCurrentText(QStringLiteral("Dark"));
m_clearTerminalButton = new QPushButton(QStringLiteral("Clear"), this); m_clearTerminalButton = new QPushButton(QStringLiteral("Clear"), this);
terminalHeader->addWidget(terminalLabel); terminalHeader->addWidget(terminalLabel);
terminalHeader->addStretch(); terminalHeader->addStretch();
terminalHeader->addWidget(new QLabel(QStringLiteral("Theme"), this));
terminalHeader->addWidget(m_themeSelector);
terminalHeader->addWidget(m_clearTerminalButton); terminalHeader->addWidget(m_clearTerminalButton);
m_terminalOutput = new TerminalView(this); m_terminalOutput = new TerminalView(this);
m_terminalOutput->setMaximumBlockCount(4000);
QFont terminalFont(QStringLiteral("Monospace")); QFont terminalFont(QStringLiteral("Monospace"));
terminalFont.setStyleHint(QFont::TypeWriter); terminalFont.setStyleHint(QFont::TypeWriter);
m_terminalOutput->setFont(terminalFont); m_terminalOutput->setFont(terminalFont);
@@ -338,6 +339,10 @@ void SessionTab::setupUi()
&TerminalView::inputGenerated, &TerminalView::inputGenerated,
this, this,
[this](const QString& input) { emit requestInput(input); }); [this](const QString& input) { emit requestInput(input); });
connect(m_themeSelector,
&QComboBox::currentTextChanged,
m_terminalOutput,
&TerminalView::setThemeName);
} }
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions() std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()

View File

@@ -10,6 +10,7 @@
#include <optional> #include <optional>
class QLabel; class QLabel;
class QComboBox;
class QPlainTextEdit; class QPlainTextEdit;
class QPushButton; class QPushButton;
class QThread; class QThread;
@@ -64,6 +65,7 @@ private:
QPushButton* m_reconnectButton; QPushButton* m_reconnectButton;
QPushButton* m_copyErrorButton; QPushButton* m_copyErrorButton;
QPushButton* m_clearTerminalButton; QPushButton* m_clearTerminalButton;
QComboBox* m_themeSelector;
QToolButton* m_toggleDetailsButton; QToolButton* m_toggleDetailsButton;
QToolButton* m_toggleEventsButton; QToolButton* m_toggleEventsButton;
QWidget* m_detailsPanel; QWidget* m_detailsPanel;

View File

@@ -2,12 +2,119 @@
#include <QApplication> #include <QApplication>
#include <QClipboard> #include <QClipboard>
#include <QColor>
#include <QKeyEvent> #include <QKeyEvent>
#include <QTextCursor>
TerminalView::TerminalView(QWidget* parent) : QPlainTextEdit(parent) #include <algorithm>
namespace {
QString normalizedThemeName(const QString& value)
{
return value.trimmed().toLower();
}
}
TerminalView::TerminalView(QWidget* parent)
: QTextEdit(parent),
m_bold(false),
m_hasFgColor(false),
m_hasBgColor(false)
{ {
setReadOnly(true); setReadOnly(true);
setUndoRedoEnabled(false); setUndoRedoEnabled(false);
document()->setMaximumBlockCount(4000);
setAcceptRichText(false);
applyThemePalette(paletteByName(QStringLiteral("Dark")));
resetSgrState();
}
QStringList TerminalView::themeNames()
{
return {QStringLiteral("Dark"), QStringLiteral("Light"), QStringLiteral("Solarized Dark")};
}
void TerminalView::setThemeName(const QString& themeName)
{
applyThemePalette(paletteByName(themeName));
}
void TerminalView::appendTerminalData(const QString& data)
{
if (data.isEmpty()) {
return;
}
const QString merged = m_pendingEscape + data;
m_pendingEscape.clear();
QString plainBuffer;
for (int i = 0; i < merged.size();) {
const QChar ch = merged.at(i);
if (ch == QChar::fromLatin1('\x1b')) {
if (!plainBuffer.isEmpty()) {
appendTextChunk(plainBuffer);
plainBuffer.clear();
}
if (i + 1 >= merged.size()) {
m_pendingEscape = merged.mid(i);
break;
}
if (merged.at(i + 1) != QChar::fromLatin1('[')) {
i += 2;
continue;
}
int end = i + 2;
while (end < merged.size()) {
const ushort c = merged.at(end).unicode();
if (c >= 0x40 && c <= 0x7e) {
break;
}
++end;
}
if (end >= merged.size()) {
m_pendingEscape = merged.mid(i);
break;
}
const QChar finalByte = merged.at(end);
const QString params = merged.mid(i + 2, end - (i + 2));
if (finalByte == QChar::fromLatin1('m')) {
handleSgrSequence(params);
} else if (finalByte == QChar::fromLatin1('J')) {
if (params.isEmpty() || params == QStringLiteral("2")) {
clear();
}
}
i = end + 1;
continue;
}
if (ch == QChar::fromLatin1('\r')) {
const bool hasLfAfter = (i + 1 < merged.size() && merged.at(i + 1) == QChar::fromLatin1('\n'));
if (!hasLfAfter) {
plainBuffer.append(QChar::fromLatin1('\n'));
}
++i;
continue;
}
plainBuffer.append(ch);
++i;
}
if (!plainBuffer.isEmpty()) {
appendTextChunk(plainBuffer);
}
} }
void TerminalView::keyPressEvent(QKeyEvent* event) void TerminalView::keyPressEvent(QKeyEvent* event)
@@ -74,5 +181,288 @@ void TerminalView::keyPressEvent(QKeyEvent* event)
return; return;
} }
QPlainTextEdit::keyPressEvent(event); QTextEdit::keyPressEvent(event);
}
TerminalView::ThemePalette TerminalView::paletteByName(const QString& themeName)
{
const QString theme = normalizedThemeName(themeName);
if (theme == QStringLiteral("light")) {
return ThemePalette{QStringLiteral("Light"),
QColor(QStringLiteral("#fafafa")),
QColor(QStringLiteral("#202124")),
{QColor(QStringLiteral("#000000")),
QColor(QStringLiteral("#a31515")),
QColor(QStringLiteral("#008000")),
QColor(QStringLiteral("#795e26")),
QColor(QStringLiteral("#0000ff")),
QColor(QStringLiteral("#af00db")),
QColor(QStringLiteral("#0451a5")),
QColor(QStringLiteral("#666666"))},
{QColor(QStringLiteral("#7f7f7f")),
QColor(QStringLiteral("#cd3131")),
QColor(QStringLiteral("#14a10e")),
QColor(QStringLiteral("#b5ba00")),
QColor(QStringLiteral("#0451a5")),
QColor(QStringLiteral("#bc05bc")),
QColor(QStringLiteral("#0598bc")),
QColor(QStringLiteral("#111111"))}};
}
if (theme == QStringLiteral("solarized dark")) {
return ThemePalette{QStringLiteral("Solarized Dark"),
QColor(QStringLiteral("#002b36")),
QColor(QStringLiteral("#839496")),
{QColor(QStringLiteral("#073642")),
QColor(QStringLiteral("#dc322f")),
QColor(QStringLiteral("#859900")),
QColor(QStringLiteral("#b58900")),
QColor(QStringLiteral("#268bd2")),
QColor(QStringLiteral("#d33682")),
QColor(QStringLiteral("#2aa198")),
QColor(QStringLiteral("#eee8d5"))},
{QColor(QStringLiteral("#586e75")),
QColor(QStringLiteral("#cb4b16")),
QColor(QStringLiteral("#586e75")),
QColor(QStringLiteral("#657b83")),
QColor(QStringLiteral("#839496")),
QColor(QStringLiteral("#6c71c4")),
QColor(QStringLiteral("#93a1a1")),
QColor(QStringLiteral("#fdf6e3"))}};
}
return ThemePalette{QStringLiteral("Dark"),
QColor(QStringLiteral("#1e1e1e")),
QColor(QStringLiteral("#d4d4d4")),
{QColor(QStringLiteral("#000000")),
QColor(QStringLiteral("#cd3131")),
QColor(QStringLiteral("#0dbc79")),
QColor(QStringLiteral("#e5e510")),
QColor(QStringLiteral("#2472c8")),
QColor(QStringLiteral("#bc3fbc")),
QColor(QStringLiteral("#11a8cd")),
QColor(QStringLiteral("#e5e5e5"))},
{QColor(QStringLiteral("#666666")),
QColor(QStringLiteral("#f14c4c")),
QColor(QStringLiteral("#23d18b")),
QColor(QStringLiteral("#f5f543")),
QColor(QStringLiteral("#3b8eea")),
QColor(QStringLiteral("#d670d6")),
QColor(QStringLiteral("#29b8db")),
QColor(QStringLiteral("#ffffff"))}};
}
QColor TerminalView::colorFrom256Index(int index)
{
if (index < 0) {
index = 0;
}
if (index > 255) {
index = 255;
}
if (index < 16) {
static const std::array<QColor, 16> base = {
QColor(QStringLiteral("#000000")), QColor(QStringLiteral("#800000")),
QColor(QStringLiteral("#008000")), QColor(QStringLiteral("#808000")),
QColor(QStringLiteral("#000080")), QColor(QStringLiteral("#800080")),
QColor(QStringLiteral("#008080")), QColor(QStringLiteral("#c0c0c0")),
QColor(QStringLiteral("#808080")), QColor(QStringLiteral("#ff0000")),
QColor(QStringLiteral("#00ff00")), QColor(QStringLiteral("#ffff00")),
QColor(QStringLiteral("#0000ff")), QColor(QStringLiteral("#ff00ff")),
QColor(QStringLiteral("#00ffff")), QColor(QStringLiteral("#ffffff"))};
return base.at(static_cast<size_t>(index));
}
if (index >= 16 && index <= 231) {
const int c = index - 16;
const int r = c / 36;
const int g = (c / 6) % 6;
const int b = c % 6;
const auto channel = [](int v) { return v == 0 ? 0 : 55 + v * 40; };
return QColor(channel(r), channel(g), channel(b));
}
const int gray = 8 + (index - 232) * 10;
return QColor(gray, gray, gray);
}
void TerminalView::applyThemePalette(const ThemePalette& palette)
{
m_palette = palette;
const QString stylesheet = QStringLiteral("QTextEdit { background: %1; color: %2; }")
.arg(m_palette.background.name(), m_palette.foreground.name());
setStyleSheet(stylesheet);
if (!m_hasFgColor) {
m_fgColor = m_palette.foreground;
}
if (!m_hasBgColor) {
m_bgColor = m_palette.background;
}
applyCurrentFormat();
}
void TerminalView::applyCurrentFormat()
{
m_currentFormat = QTextCharFormat();
m_currentFormat.setForeground(m_hasFgColor ? m_fgColor : m_palette.foreground);
if (m_hasBgColor) {
m_currentFormat.setBackground(m_bgColor);
}
QFont font = currentFont();
font.setBold(m_bold);
m_currentFormat.setFont(font);
}
void TerminalView::resetSgrState()
{
m_bold = false;
m_hasFgColor = false;
m_hasBgColor = false;
m_fgColor = m_palette.foreground;
m_bgColor = m_palette.background;
applyCurrentFormat();
}
void TerminalView::handleSgrSequence(const QString& params)
{
QStringList parts = params.split(QChar::fromLatin1(';'), Qt::KeepEmptyParts);
if (parts.isEmpty()) {
parts.push_back(QStringLiteral("0"));
}
for (int i = 0; i < parts.size(); ++i) {
const QString part = parts.at(i).trimmed();
bool ok = false;
int code = part.isEmpty() ? 0 : part.toInt(&ok);
if (!ok && !part.isEmpty()) {
continue;
}
if (code == 0) {
resetSgrState();
continue;
}
if (code == 1) {
m_bold = true;
continue;
}
if (code == 22) {
m_bold = false;
continue;
}
if (code == 39) {
m_hasFgColor = false;
continue;
}
if (code == 49) {
m_hasBgColor = false;
continue;
}
if (code >= 30 && code <= 37) {
m_fgColor = paletteColor(false, code - 30, false);
m_hasFgColor = true;
continue;
}
if (code >= 90 && code <= 97) {
m_fgColor = paletteColor(false, code - 90, true);
m_hasFgColor = true;
continue;
}
if (code >= 40 && code <= 47) {
m_bgColor = paletteColor(true, code - 40, false);
m_hasBgColor = true;
continue;
}
if (code >= 100 && code <= 107) {
m_bgColor = paletteColor(true, code - 100, true);
m_hasBgColor = true;
continue;
}
if (code == 38 || code == 48) {
const bool background = (code == 48);
if (i + 1 >= parts.size()) {
continue;
}
const int mode = parts.at(i + 1).toInt(&ok);
if (!ok) {
continue;
}
if (mode == 5 && i + 2 < parts.size()) {
const int index = parts.at(i + 2).toInt(&ok);
if (ok) {
QColor color = colorFrom256Index(index);
if (background) {
m_bgColor = color;
m_hasBgColor = true;
} else {
m_fgColor = color;
m_hasFgColor = true;
}
}
i += 2;
continue;
}
if (mode == 2 && i + 4 < parts.size()) {
const int r = parts.at(i + 2).toInt(&ok);
if (!ok) {
i += 4;
continue;
}
const int g = parts.at(i + 3).toInt(&ok);
if (!ok) {
i += 4;
continue;
}
const int b = parts.at(i + 4).toInt(&ok);
if (!ok) {
i += 4;
continue;
}
const QColor color(r, g, b);
if (background) {
m_bgColor = color;
m_hasBgColor = true;
} else {
m_fgColor = color;
m_hasFgColor = true;
}
i += 4;
continue;
}
}
}
applyCurrentFormat();
}
void TerminalView::appendTextChunk(const QString& text)
{
if (text.isEmpty()) {
return;
}
QTextCursor cursor = textCursor();
cursor.movePosition(QTextCursor::End);
cursor.insertText(text, m_currentFormat);
setTextCursor(cursor);
ensureCursorVisible();
}
QColor TerminalView::paletteColor(bool, int index, bool bright) const
{
const int safeIndex = std::clamp(index, 0, 7);
return bright ? m_palette.bright.at(static_cast<size_t>(safeIndex))
: m_palette.normal.at(static_cast<size_t>(safeIndex));
} }

View File

@@ -1,20 +1,56 @@
#ifndef ORBITHUB_TERMINAL_VIEW_H #ifndef ORBITHUB_TERMINAL_VIEW_H
#define ORBITHUB_TERMINAL_VIEW_H #define ORBITHUB_TERMINAL_VIEW_H
#include <QPlainTextEdit> #include <QTextEdit>
class TerminalView : public QPlainTextEdit #include <array>
class QKeyEvent;
class TerminalView : public QTextEdit
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit TerminalView(QWidget* parent = nullptr); explicit TerminalView(QWidget* parent = nullptr);
static QStringList themeNames();
void setThemeName(const QString& themeName);
void appendTerminalData(const QString& data);
signals: signals:
void inputGenerated(const QString& input); void inputGenerated(const QString& input);
protected: protected:
void keyPressEvent(QKeyEvent* event) override; void keyPressEvent(QKeyEvent* event) override;
private:
struct ThemePalette {
QString name;
QColor background;
QColor foreground;
std::array<QColor, 8> normal;
std::array<QColor, 8> bright;
};
ThemePalette m_palette;
QString m_pendingEscape;
bool m_bold;
bool m_hasFgColor;
bool m_hasBgColor;
QColor m_fgColor;
QColor m_bgColor;
QTextCharFormat m_currentFormat;
static ThemePalette paletteByName(const QString& themeName);
static QColor colorFrom256Index(int index);
void applyThemePalette(const ThemePalette& palette);
void applyCurrentFormat();
void resetSgrState();
void handleSgrSequence(const QString& params);
void appendTextChunk(const QString& text);
QColor paletteColor(bool background, int index, bool bright) const;
}; };
#endif #endif