Improve terminal theming, cursor UX, and size negotiation

This commit is contained in:
Keith Smith
2026-03-01 10:14:43 -07:00
parent 20ee48db32
commit c3369b8e48
9 changed files with 141 additions and 24 deletions

View File

@@ -43,6 +43,7 @@ public slots:
virtual void reconnectSession(const SessionConnectOptions& options) = 0; virtual void reconnectSession(const SessionConnectOptions& options) = 0;
virtual void sendInput(const QString& input) = 0; virtual void sendInput(const QString& input) = 0;
virtual void confirmHostKey(bool trustHost) = 0; virtual void confirmHostKey(bool trustHost) = 0;
virtual void updateTerminalSize(int columns, int rows) = 0;
signals: signals:
void stateChanged(SessionState state, const QString& message); void stateChanged(SessionState state, const QString& message);

View File

@@ -79,6 +79,11 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_backend, m_backend,
&SessionBackend::confirmHostKey, &SessionBackend::confirmHostKey,
Qt::QueuedConnection); Qt::QueuedConnection);
connect(this,
&SessionTab::requestTerminalSize,
m_backend,
&SessionBackend::updateTerminalSize,
Qt::QueuedConnection);
connect(m_backend, connect(m_backend,
&SessionBackend::stateChanged, &SessionBackend::stateChanged,
@@ -172,6 +177,11 @@ void SessionTab::onCopyErrorClicked()
void SessionTab::onClearTerminalClicked() void SessionTab::onClearTerminalClicked()
{ {
m_terminalOutput->clear(); m_terminalOutput->clear();
if (m_state == SessionState::Connected) {
// Ask the remote shell to repaint a prompt after local clear.
emit requestInput(QStringLiteral("\x0c"));
}
m_terminalOutput->setFocus();
} }
void SessionTab::onBackendStateChanged(SessionState state, const QString& message) void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
@@ -339,6 +349,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_terminalOutput,
&TerminalView::terminalSizeChanged,
this,
[this](int columns, int rows) { emit requestTerminalSize(columns, rows); });
connect(m_themeSelector, connect(m_themeSelector,
&QComboBox::currentTextChanged, &QComboBox::currentTextChanged,
m_terminalOutput, m_terminalOutput,
@@ -493,9 +507,7 @@ void SessionTab::refreshActionButtons()
m_copyErrorButton->setEnabled(!m_lastError.isEmpty()); m_copyErrorButton->setEnabled(!m_lastError.isEmpty());
m_terminalOutput->setEnabled(isConnected); m_terminalOutput->setEnabled(isConnected);
if (isConnected) { m_terminalOutput->setFocus();
m_terminalOutput->setFocus();
}
} }
void SessionTab::setPanelExpanded(QToolButton* button, void SessionTab::setPanelExpanded(QToolButton* button,

View File

@@ -35,6 +35,7 @@ signals:
void requestReconnect(const SessionConnectOptions& options); void requestReconnect(const SessionConnectOptions& options);
void requestInput(const QString& input); void requestInput(const QString& input);
void requestHostKeyConfirmation(bool trustHost); void requestHostKeyConfirmation(bool trustHost);
void requestTerminalSize(int columns, int rows);
private slots: private slots:
void onConnectClicked(); void onConnectClicked();

View File

@@ -25,7 +25,9 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
m_reconnectPending(false), m_reconnectPending(false),
m_waitingForPasswordPrompt(false), m_waitingForPasswordPrompt(false),
m_waitingForHostKeyConfirmation(false), m_waitingForHostKeyConfirmation(false),
m_passwordSubmitted(false) m_passwordSubmitted(false),
m_terminalColumns(0),
m_terminalRows(0)
{ {
m_connectedProbeTimer->setSingleShot(true); m_connectedProbeTimer->setSingleShot(true);
@@ -152,6 +154,16 @@ void SshSessionBackend::confirmHostKey(bool trustHost)
: QStringLiteral("Host key rejected by user.")); : QStringLiteral("Host key rejected by user."));
} }
void SshSessionBackend::updateTerminalSize(int columns, int rows)
{
m_terminalColumns = columns;
m_terminalRows = rows;
if (m_state == SessionState::Connected) {
applyTerminalSizeIfAvailable();
}
}
void SshSessionBackend::onProcessStarted() void SshSessionBackend::onProcessStarted()
{ {
emit eventLogged(QStringLiteral("ssh process started.")); emit eventLogged(QStringLiteral("ssh process started."));
@@ -284,6 +296,10 @@ void SshSessionBackend::setState(SessionState state, const QString& message)
m_state = state; m_state = state;
emit stateChanged(state, message); emit stateChanged(state, message);
emit eventLogged(message); emit eventLogged(message);
if (m_state == SessionState::Connected) {
applyTerminalSizeIfAvailable();
}
} }
bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options) bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
@@ -502,3 +518,21 @@ QString SshSessionBackend::knownHostsFileForNullDevice() const
return QStringLiteral("/dev/null"); return QStringLiteral("/dev/null");
#endif #endif
} }
void SshSessionBackend::applyTerminalSizeIfAvailable()
{
if (m_process->state() != QProcess::Running) {
return;
}
if (m_terminalColumns <= 0 || m_terminalRows <= 0) {
return;
}
const QString command = QStringLiteral("stty cols %1 rows %2\\n")
.arg(m_terminalColumns)
.arg(m_terminalRows);
m_process->write(command.toUtf8());
emit eventLogged(
QStringLiteral("Applied terminal size: %1x%2").arg(m_terminalColumns).arg(m_terminalRows));
}

View File

@@ -21,6 +21,7 @@ public slots:
void reconnectSession(const SessionConnectOptions& options) override; void reconnectSession(const SessionConnectOptions& options) override;
void sendInput(const QString& input) override; void sendInput(const QString& input) override;
void confirmHostKey(bool trustHost) override; void confirmHostKey(bool trustHost) override;
void updateTerminalSize(int columns, int rows) override;
private slots: private slots:
void onProcessStarted(); void onProcessStarted();
@@ -43,6 +44,8 @@ private:
bool m_waitingForPasswordPrompt; bool m_waitingForPasswordPrompt;
bool m_waitingForHostKeyConfirmation; bool m_waitingForHostKeyConfirmation;
bool m_passwordSubmitted; bool m_passwordSubmitted;
int m_terminalColumns;
int m_terminalRows;
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
bool startSshProcess(const SessionConnectOptions& options); bool startSshProcess(const SessionConnectOptions& options);
@@ -52,6 +55,7 @@ private:
void cleanupAskPassScript(); void cleanupAskPassScript();
QString mapSshError(const QString& rawError) const; QString mapSshError(const QString& rawError) const;
QString knownHostsFileForNullDevice() const; QString knownHostsFileForNullDevice() const;
void applyTerminalSizeIfAvailable();
}; };
#endif #endif

View File

@@ -3,7 +3,11 @@
#include <QApplication> #include <QApplication>
#include <QClipboard> #include <QClipboard>
#include <QColor> #include <QColor>
#include <QFocusEvent>
#include <QFontMetrics>
#include <QKeyEvent> #include <QKeyEvent>
#include <QResizeEvent>
#include <QTimer>
#include <QTextCursor> #include <QTextCursor>
#include <algorithm> #include <algorithm>
@@ -21,13 +25,21 @@ TerminalView::TerminalView(QWidget* parent)
m_hasFgColor(false), m_hasFgColor(false),
m_hasBgColor(false) m_hasBgColor(false)
{ {
setReadOnly(true); setReadOnly(false);
setUndoRedoEnabled(false); setUndoRedoEnabled(false);
document()->setMaximumBlockCount(4000);
setAcceptRichText(false); setAcceptRichText(false);
setLineWrapMode(QTextEdit::NoWrap);
setContextMenuPolicy(Qt::NoContextMenu);
setCursorWidth(2);
document()->setMaximumBlockCount(4000);
applyThemePalette(paletteByName(QStringLiteral("Dark"))); applyThemePalette(paletteByName(QStringLiteral("Dark")));
resetSgrState(); resetSgrState();
QTimer::singleShot(0, this, [this]() {
moveCursor(QTextCursor::End);
emitTerminalSize();
});
} }
QStringList TerminalView::themeNames() QStringList TerminalView::themeNames()
@@ -123,8 +135,19 @@ void TerminalView::keyPressEvent(QKeyEvent* event)
return; return;
} }
moveCursor(QTextCursor::End);
const Qt::KeyboardModifiers modifiers = event->modifiers(); const Qt::KeyboardModifiers modifiers = event->modifiers();
if (modifiers == (Qt::ControlModifier | Qt::ShiftModifier)
&& event->key() == Qt::Key_C) {
const QString selected = textCursor().selectedText();
if (!selected.isEmpty()) {
QApplication::clipboard()->setText(selected);
}
return;
}
if (modifiers == Qt::ControlModifier) { if (modifiers == Qt::ControlModifier) {
switch (event->key()) { switch (event->key()) {
case Qt::Key_C: case Qt::Key_C:
@@ -180,8 +203,18 @@ void TerminalView::keyPressEvent(QKeyEvent* event)
emit inputGenerated(text); emit inputGenerated(text);
return; return;
} }
}
QTextEdit::keyPressEvent(event); void TerminalView::focusInEvent(QFocusEvent* event)
{
QTextEdit::focusInEvent(event);
moveCursor(QTextCursor::End);
}
void TerminalView::resizeEvent(QResizeEvent* event)
{
QTextEdit::resizeEvent(event);
emitTerminalSize();
} }
TerminalView::ThemePalette TerminalView::paletteByName(const QString& themeName) TerminalView::ThemePalette TerminalView::paletteByName(const QString& themeName)
@@ -190,23 +223,23 @@ TerminalView::ThemePalette TerminalView::paletteByName(const QString& themeName)
if (theme == QStringLiteral("light")) { if (theme == QStringLiteral("light")) {
return ThemePalette{QStringLiteral("Light"), return ThemePalette{QStringLiteral("Light"),
QColor(QStringLiteral("#fafafa")), QColor(QStringLiteral("#ececec")),
QColor(QStringLiteral("#202124")), QColor(QStringLiteral("#000000")),
{QColor(QStringLiteral("#000000")), {QColor(QStringLiteral("#000000")),
QColor(QStringLiteral("#a31515")), QColor(QStringLiteral("#aa0000")),
QColor(QStringLiteral("#008000")), QColor(QStringLiteral("#008000")),
QColor(QStringLiteral("#795e26")), QColor(QStringLiteral("#7a5f00")),
QColor(QStringLiteral("#0000ff")), QColor(QStringLiteral("#0033cc")),
QColor(QStringLiteral("#af00db")), QColor(QStringLiteral("#8a00a8")),
QColor(QStringLiteral("#0451a5")), QColor(QStringLiteral("#005f87")),
QColor(QStringLiteral("#666666"))}, QColor(QStringLiteral("#333333"))},
{QColor(QStringLiteral("#7f7f7f")), {QColor(QStringLiteral("#5c5c5c")),
QColor(QStringLiteral("#cd3131")), QColor(QStringLiteral("#d30000")),
QColor(QStringLiteral("#14a10e")), QColor(QStringLiteral("#00a000")),
QColor(QStringLiteral("#b5ba00")), QColor(QStringLiteral("#9a7700")),
QColor(QStringLiteral("#0451a5")), QColor(QStringLiteral("#0055ff")),
QColor(QStringLiteral("#bc05bc")), QColor(QStringLiteral("#b300db")),
QColor(QStringLiteral("#0598bc")), QColor(QStringLiteral("#007ea7")),
QColor(QStringLiteral("#111111"))}}; QColor(QStringLiteral("#111111"))}};
} }
@@ -338,7 +371,7 @@ void TerminalView::handleSgrSequence(const QString& params)
for (int i = 0; i < parts.size(); ++i) { for (int i = 0; i < parts.size(); ++i) {
const QString part = parts.at(i).trimmed(); const QString part = parts.at(i).trimmed();
bool ok = false; bool ok = false;
int code = part.isEmpty() ? 0 : part.toInt(&ok); const int code = part.isEmpty() ? 0 : part.toInt(&ok);
if (!ok && !part.isEmpty()) { if (!ok && !part.isEmpty()) {
continue; continue;
} }
@@ -399,7 +432,7 @@ void TerminalView::handleSgrSequence(const QString& params)
if (mode == 5 && i + 2 < parts.size()) { if (mode == 5 && i + 2 < parts.size()) {
const int index = parts.at(i + 2).toInt(&ok); const int index = parts.at(i + 2).toInt(&ok);
if (ok) { if (ok) {
QColor color = colorFrom256Index(index); const QColor color = colorFrom256Index(index);
if (background) { if (background) {
m_bgColor = color; m_bgColor = color;
m_hasBgColor = true; m_hasBgColor = true;
@@ -466,3 +499,22 @@ QColor TerminalView::paletteColor(bool, int index, bool bright) const
return bright ? m_palette.bright.at(static_cast<size_t>(safeIndex)) return bright ? m_palette.bright.at(static_cast<size_t>(safeIndex))
: m_palette.normal.at(static_cast<size_t>(safeIndex)); : m_palette.normal.at(static_cast<size_t>(safeIndex));
} }
int TerminalView::terminalColumns() const
{
const QFontMetrics metrics(font());
const int cellWidth = std::max(1, metrics.horizontalAdvance(QChar::fromLatin1('M')));
return std::max(1, viewport()->width() / cellWidth);
}
int TerminalView::terminalRows() const
{
const QFontMetrics metrics(font());
const int cellHeight = std::max(1, metrics.lineSpacing());
return std::max(1, viewport()->height() / cellHeight);
}
void TerminalView::emitTerminalSize()
{
emit terminalSizeChanged(terminalColumns(), terminalRows());
}

View File

@@ -6,6 +6,8 @@
#include <array> #include <array>
class QKeyEvent; class QKeyEvent;
class QFocusEvent;
class QResizeEvent;
class TerminalView : public QTextEdit class TerminalView : public QTextEdit
{ {
@@ -20,9 +22,12 @@ public:
signals: signals:
void inputGenerated(const QString& input); void inputGenerated(const QString& input);
void terminalSizeChanged(int columns, int rows);
protected: protected:
void keyPressEvent(QKeyEvent* event) override; void keyPressEvent(QKeyEvent* event) override;
void focusInEvent(QFocusEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
private: private:
struct ThemePalette { struct ThemePalette {
@@ -51,6 +56,9 @@ private:
void handleSgrSequence(const QString& params); void handleSgrSequence(const QString& params);
void appendTextChunk(const QString& text); void appendTextChunk(const QString& text);
QColor paletteColor(bool background, int index, bool bright) const; QColor paletteColor(bool background, int index, bool bright) const;
int terminalColumns() const;
int terminalRows() const;
void emitTerminalSize();
}; };
#endif #endif

View File

@@ -33,3 +33,7 @@ void UnsupportedSessionBackend::sendInput(const QString&)
void UnsupportedSessionBackend::confirmHostKey(bool) void UnsupportedSessionBackend::confirmHostKey(bool)
{ {
} }
void UnsupportedSessionBackend::updateTerminalSize(int, int)
{
}

View File

@@ -16,6 +16,7 @@ public slots:
void reconnectSession(const SessionConnectOptions& options) override; void reconnectSession(const SessionConnectOptions& options) override;
void sendInput(const QString& input) override; void sendInput(const QString& input) override;
void confirmHostKey(bool trustHost) override; void confirmHostKey(bool trustHost) override;
void updateTerminalSize(int columns, int rows) override;
}; };
#endif #endif