From 20ee48db323fb7f96ef43372d81f130d6f1623dd Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sun, 1 Mar 2026 10:08:04 -0700 Subject: [PATCH] Add ANSI color rendering and terminal themes --- src/session_tab.cpp | 19 +- src/session_tab.h | 2 + src/terminal_view.cpp | 394 +++++++++++++++++++++++++++++++++++++++++- src/terminal_view.h | 40 ++++- 4 files changed, 444 insertions(+), 11 deletions(-) diff --git a/src/session_tab.cpp b/src/session_tab.cpp index 8ec6a3d..5083ad1 100644 --- a/src/session_tab.cpp +++ b/src/session_tab.cpp @@ -4,6 +4,7 @@ #include "terminal_view.h" #include +#include #include #include #include @@ -16,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -38,6 +38,7 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent) m_reconnectButton(nullptr), m_copyErrorButton(nullptr), m_clearTerminalButton(nullptr), + m_themeSelector(nullptr), m_toggleDetailsButton(nullptr), m_toggleEventsButton(nullptr), m_detailsPanel(nullptr), @@ -196,11 +197,7 @@ void SessionTab::onBackendOutputReceived(const QString& text) return; } - QTextCursor cursor = m_terminalOutput->textCursor(); - cursor.movePosition(QTextCursor::End); - cursor.insertText(text); - m_terminalOutput->setTextCursor(cursor); - m_terminalOutput->ensureCursorVisible(); + m_terminalOutput->appendTerminalData(text); } void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt) @@ -268,13 +265,17 @@ void SessionTab::setupUi() auto* terminalHeader = new QHBoxLayout(); 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); terminalHeader->addWidget(terminalLabel); terminalHeader->addStretch(); + terminalHeader->addWidget(new QLabel(QStringLiteral("Theme"), this)); + terminalHeader->addWidget(m_themeSelector); terminalHeader->addWidget(m_clearTerminalButton); m_terminalOutput = new TerminalView(this); - m_terminalOutput->setMaximumBlockCount(4000); QFont terminalFont(QStringLiteral("Monospace")); terminalFont.setStyleHint(QFont::TypeWriter); m_terminalOutput->setFont(terminalFont); @@ -338,6 +339,10 @@ void SessionTab::setupUi() &TerminalView::inputGenerated, this, [this](const QString& input) { emit requestInput(input); }); + connect(m_themeSelector, + &QComboBox::currentTextChanged, + m_terminalOutput, + &TerminalView::setThemeName); } std::optional SessionTab::buildConnectOptions() diff --git a/src/session_tab.h b/src/session_tab.h index dc5ed05..0beed74 100644 --- a/src/session_tab.h +++ b/src/session_tab.h @@ -10,6 +10,7 @@ #include class QLabel; +class QComboBox; class QPlainTextEdit; class QPushButton; class QThread; @@ -64,6 +65,7 @@ private: QPushButton* m_reconnectButton; QPushButton* m_copyErrorButton; QPushButton* m_clearTerminalButton; + QComboBox* m_themeSelector; QToolButton* m_toggleDetailsButton; QToolButton* m_toggleEventsButton; QWidget* m_detailsPanel; diff --git a/src/terminal_view.cpp b/src/terminal_view.cpp index 0bb6e17..6b1ed7c 100644 --- a/src/terminal_view.cpp +++ b/src/terminal_view.cpp @@ -2,12 +2,119 @@ #include #include +#include #include +#include -TerminalView::TerminalView(QWidget* parent) : QPlainTextEdit(parent) +#include + +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); 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) @@ -74,5 +181,288 @@ void TerminalView::keyPressEvent(QKeyEvent* event) 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 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(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(safeIndex)) + : m_palette.normal.at(static_cast(safeIndex)); } diff --git a/src/terminal_view.h b/src/terminal_view.h index b3591b4..70ff7da 100644 --- a/src/terminal_view.h +++ b/src/terminal_view.h @@ -1,20 +1,56 @@ #ifndef ORBITHUB_TERMINAL_VIEW_H #define ORBITHUB_TERMINAL_VIEW_H -#include +#include -class TerminalView : public QPlainTextEdit +#include + +class QKeyEvent; + +class TerminalView : public QTextEdit { Q_OBJECT public: explicit TerminalView(QWidget* parent = nullptr); + static QStringList themeNames(); + void setThemeName(const QString& themeName); + void appendTerminalData(const QString& data); + signals: void inputGenerated(const QString& input); protected: void keyPressEvent(QKeyEvent* event) override; + +private: + struct ThemePalette { + QString name; + QColor background; + QColor foreground; + std::array normal; + std::array 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