xK/xT/xT.cpp
Přemysl Eric Janouch 7ba17a0161
All checks were successful
Alpine 3.21 Success
Arch Linux AUR Success
OpenBSD 7.6 Success
Make the relay acknowledge all received commands
To that effect, bump liberty and the xC relay protocol version.
Relay events have been reordered to improve forward compatibility.

Also prevent use-after-free when serialization fails.

xP now slightly throttles activity notifications,
and indicates when there are unacknowledged commands.
2025-05-10 12:08:51 +02:00

1735 lines
46 KiB
C++

/*
* xT.cpp: Qt frontend for xC
*
* Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
* SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
* OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
* CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*
*/
#include "xC-proto.cpp"
#include "config.h"
#include <cstdint>
#include <functional>
#include <map>
#include <string>
#include <QtEndian>
#include <QtDebug>
#include <QDateTime>
#include <QRegularExpression>
#include <QApplication>
#include <QFontDatabase>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QListWidget>
#include <QMainWindow>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollBar>
#include <QShortcut>
#include <QSplitter>
#include <QStackedWidget>
#include <QTextBrowser>
#include <QTextBlock>
#include <QTextEdit>
#include <QTimer>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWindow>
#include <QSoundEffect>
#include <QTcpSocket>
struct Server {
Relay::ServerState state = {};
QString user;
QString user_modes;
};
struct BufferLineItem {
QTextCharFormat format = {};
QString text;
};
struct BufferLine {
/// Leaked from another buffer, but temporarily staying in another one.
bool leaked = {};
bool is_unimportant = {};
bool is_highlight = {};
Relay::Rendition rendition = {};
uint64_t when = {};
std::vector<BufferLineItem> items;
};
struct Buffer {
QString buffer_name;
bool hide_unimportant = {};
Relay::BufferKind kind = {};
QString server_name;
std::vector<BufferLine> lines;
// Channel:
std::vector<BufferLineItem> topic;
QString modes;
// Stats:
uint32_t new_messages = {};
uint32_t new_unimportant_messages = {};
bool highlighted = {};
// Input:
// The input is stored as rich text.
QString input;
int input_start = {};
int input_end = {};
std::vector<QString> history;
size_t history_at = {};
};
using Callback = std::function<
void(std::wstring error, const Relay::ResponseData *response)>;
struct {
QMainWindow *wMain; ///< Main program window
QLabel *wTopic; ///< Channel topic
QListWidget *wBufferList; ///< Buffer list
QStackedWidget *wStack; ///< Buffer backlog/log stack
QTextBrowser *wBuffer; ///< Buffer backlog
QTextBrowser *wLog; ///< Buffer log
QLabel *wPrompt; ///< User name, etc.
QToolButton *wButtonB; ///< Toggle bold formatting
QToolButton *wButtonI; ///< Toggle italic formatting
QToolButton *wButtonU; ///< Toggle underlined formatting
QLabel *wStatus; ///< Buffer name, etc.
QToolButton *wButtonLog; ///< Buffer log toggle button
QToolButton *wButtonDown; ///< Scroll indicator
QTextEdit *wInput; ///< User input
QTimer *date_change_timer; ///< Timer for day changes
QLineEdit *wConnectHost; ///< Connection dialog: host
QLineEdit *wConnectPort; ///< Connection dialog: port
QDialog *wConnectDialog; ///< Connection details dialog
// Networking:
QString host; ///< Host as given by user
QString port; ///< Post/service as given by user
QTcpSocket *socket; ///< Buffered relay socket
// Relay protocol:
uint32_t command_seq; ///< Outgoing message counter
std::map<uint32_t, Callback> command_callbacks;
std::vector<Buffer> buffers; ///< List of all buffers
QString buffer_current; ///< Current buffer name or ""
QString buffer_last; ///< Previous buffer name or ""
std::map<QString, Server> servers;
} g;
static void
show_error_message(const QString &message)
{
QMessageBox::critical(g.wMain, "Error", message, QMessageBox::Ok);
}
static void
beep()
{
// We don't want to reuse the same instance.
auto *se = new QSoundEffect(g.wMain);
QObject::connect(se, &QSoundEffect::playingChanged, [=] {
if (!se->isPlaying())
se->deleteLater();
});
QObject::connect(se, &QSoundEffect::statusChanged, [=] {
if (se->status() == QSoundEffect::Error)
se->deleteLater();
});
se->setSource(QUrl("qrc:/beep.wav"));
se->setLoopCount(1);
se->setVolume(0.5);
se->play();
}
// --- Networking --------------------------------------------------------------
static void
on_relay_generic_response(
std::wstring error, const Relay::ResponseData *response)
{
if (!response)
show_error_message(QString::fromStdWString(error));
}
static void
relay_send(Relay::CommandData *data, Callback callback = {})
{
Relay::CommandMessage m = {};
m.command_seq = ++g.command_seq;
m.data.reset(data);
LibertyXDR::Writer w;
m.serialize(w);
if (callback)
g.command_callbacks[m.command_seq] = std::move(callback);
else
g.command_callbacks[m.command_seq] = on_relay_generic_response;
auto len = qToBigEndian<uint32_t>(w.data.size());
auto prefix = reinterpret_cast<const char *>(&len);
auto mdata = reinterpret_cast<const char *>(w.data.data());
if (g.socket->write(prefix, sizeof len) < 0 ||
g.socket->write(mdata, w.data.size()) < 0) {
g.socket->abort();
}
}
// --- Buffers -----------------------------------------------------------------
static Buffer *
buffer_by_name(const QString &name)
{
for (auto &b : g.buffers)
if (b.buffer_name == name)
return &b;
return nullptr;
}
static Buffer *
buffer_by_name(const std::wstring &name)
{
// The C++ LibertyXDR backend unfortunately targets Win32.
return buffer_by_name(QString::fromStdWString(name));
}
static bool
buffer_at_bottom()
{
auto sb = g.wBuffer->verticalScrollBar();
return sb->value() == sb->maximum();
}
static void
buffer_scroll_to_bottom()
{
auto sb = g.wBuffer->verticalScrollBar();
sb->setValue(sb->maximum());
}
// --- UI state refresh --------------------------------------------------------
static void
refresh_icon()
{
// This blocks Linux themes, but oh well.
QIcon icon(":/xT.png");
for (const auto &b : g.buffers)
if (b.highlighted)
icon = QIcon(":/xT-highlighted.png");
g.wMain->setWindowIcon(icon);
}
static void
textedit_replacesel(
QTextEdit *e, const QTextCharFormat &cf, const QString &text)
{
auto cursor = e->textCursor();
if (cf.fontFixedPitch()) {
auto fixed = QFontDatabase::systemFont(QFontDatabase::FixedFont);
auto adjusted = cf;
// For some reason, setting the families to empty also works.
adjusted.setFontFamilies(fixed.families());
cursor.setCharFormat(adjusted);
} else {
cursor.setCharFormat(cf);
}
cursor.insertText(text);
}
static void
refresh_topic(const std::vector<BufferLineItem> &topic)
{
QTextDocument doc;
QTextCursor cursor(&doc);
for (const auto &it : topic) {
cursor.setCharFormat(it.format);
cursor.insertText(it.text);
}
g.wTopic->setText(doc.toHtml());
}
static void
refresh_buffer_list_item(QListWidgetItem *item, const Buffer &b)
{
auto text = b.buffer_name;
QFont font;
QBrush color;
if (b.buffer_name != g.buffer_current && b.new_messages) {
text += " (" + QString::number(b.new_messages) + ")";
font.setBold(true);
}
if (b.highlighted)
color = QColor(0xff, 0x5f, 0x00);
item->setForeground(color);
item->setText(text);
item->setFont(font);
}
static void
refresh_buffer_list()
{
for (size_t i = 0; i < g.buffers.size(); i++)
refresh_buffer_list_item(g.wBufferList->item(i), g.buffers.at(i));
}
static QString
server_state_to_string(Relay::ServerState state)
{
switch (state) {
case Relay::ServerState::DISCONNECTED: return "disconnected";
case Relay::ServerState::CONNECTING: return "connecting";
case Relay::ServerState::CONNECTED: return "connected";
case Relay::ServerState::REGISTERED: return "registered";
case Relay::ServerState::DISCONNECTING: return "disconnecting";
}
return {};
}
static void
refresh_prompt()
{
QString prompt;
auto b = buffer_by_name(g.buffer_current);
if (!b) {
prompt = "Synchronizing...";
} else if (auto server = g.servers.find(b->server_name);
server != g.servers.end()) {
prompt = server->second.user;
if (!server->second.user_modes.isEmpty())
prompt += "(" + server->second.user_modes + ")";
if (prompt.isEmpty())
prompt = "(" + server_state_to_string(server->second.state) + ")";
}
g.wPrompt->setText(prompt);
}
static void
refresh_status()
{
g.wButtonDown->setEnabled(!buffer_at_bottom());
QString status = g.buffer_current;
if (auto b = buffer_by_name(g.buffer_current)) {
if (!b->modes.isEmpty())
status += "(+" + b->modes + ")";
if (b->hide_unimportant)
status += "<H>";
}
// Buffer scrolling would cause a ton of flickering redraws.
if (g.wStatus->text() != status)
g.wStatus->setText(status);
}
static void
recheck_highlighted()
{
// Corresponds to the logic toggling the bool on.
auto b = buffer_by_name(g.buffer_current);
if (b && b->highlighted && buffer_at_bottom() &&
!g.wMain->isMinimized() && !g.wLog->isVisible()) {
b->highlighted = false;
refresh_icon();
refresh_buffer_list();
}
}
// --- Buffer actions ----------------------------------------------------------
static void
buffer_activate(const QString &name)
{
auto activate = new Relay::CommandData_BufferActivate();
activate->buffer_name = name.toStdWString();
relay_send(activate);
}
static void
buffer_toggle_unimportant(const QString &name)
{
auto toggle = new Relay::CommandData_BufferToggleUnimportant();
toggle->buffer_name = name.toStdWString();
relay_send(toggle);
}
// FIXME: This works on the wrong level, we should take a vector and output
// a filtered vector--we must disregard individual items during URL matching.
static void
convert_links(const QTextCharFormat &format, const QString &text,
std::vector<BufferLineItem> &result)
{
static QRegularExpression link_re(
R"(https?://)"
R"((?:[^\[\](){}<>"'\s]|\([^\[\](){}<>"'\s]*\))+)"
R"((?:[^\[\](){}<>"'\s,.:]|\([^\[\](){}<>"'\s]*\)))");
qsizetype end = 0;
for (const QRegularExpressionMatch &m : link_re.globalMatch(text)) {
if (end < m.capturedStart()) {
result.emplace_back(BufferLineItem{
format, text.sliced(end, m.capturedStart() - end)});
}
BufferLineItem item{format, m.captured()};
item.format.setAnchor(true);
item.format.setAnchorHref(m.captured());
result.emplace_back(std::move(item));
end = m.capturedEnd();
}
if (!end)
result.emplace_back(BufferLineItem{format, text});
else if (end < text.length())
result.emplace_back(BufferLineItem{format, text.sliced(end)});
}
static void
buffer_toggle_log(
const std::wstring &error, const Relay::ResponseData_BufferLog *response)
{
if (!response) {
show_error_message(QString::fromStdWString(error));
return;
}
std::wstring log;
if (!LibertyXDR::utf8_to_wstring(
response->log.data(), response->log.size(), log)) {
show_error_message("Invalid encoding.");
return;
}
std::vector<BufferLineItem> linkified;
convert_links({}, QString::fromStdWString(log), linkified);
g.wButtonLog->setChecked(true);
g.wLog->setText({});
for (const auto &it : linkified)
textedit_replacesel(g.wLog, it.format, it.text);
g.wStack->setCurrentWidget(g.wLog);
// This triggers a relayout of some kind.
auto cursor = g.wLog->textCursor();
cursor.movePosition(QTextCursor::End);
g.wLog->setTextCursor(cursor);
auto sb = g.wLog->verticalScrollBar();
sb->setValue(sb->maximum());
}
static void
buffer_toggle_log()
{
if (g.wLog->isVisible()) {
g.wStack->setCurrentWidget(g.wBuffer);
g.wLog->setText("");
g.wButtonLog->setChecked(false);
recheck_highlighted();
return;
}
auto log = new Relay::CommandData_BufferLog();
log->buffer_name = g.buffer_current.toStdWString();
relay_send(log, [name = g.buffer_current](auto error, auto response) {
if (g.buffer_current != name)
return;
buffer_toggle_log(error,
dynamic_cast<const Relay::ResponseData_BufferLog *>(response));
});
}
// --- QTextEdit formatting ----------------------------------------------------
static QString
rich_text_to_irc(QTextEdit *textEdit)
{
QString irc;
for (auto block = textEdit->document()->begin();
block.isValid(); block = block.next()) {
for (auto it = block.begin(); it != block.end(); ++it) {
auto fragment = it.fragment();
if (!fragment.isValid())
continue;
// TODO(p): Colours.
QString toggles;
auto format = fragment.charFormat();
if (format.fontWeight() >= QFont::Bold)
toggles += "\x02";
if (format.fontFixedPitch())
toggles += "\x11";
if (format.fontItalic())
toggles += "\x1d";
if (format.fontStrikeOut())
toggles += "\x1e";
if (format.fontUnderline())
toggles += "\x1f";
irc += toggles + fragment.text() + toggles;
}
if (block.next().isValid())
irc += "\n";
}
return irc;
}
static QString
irc_to_rich_text(const QString &irc)
{
QTextDocument doc;
QTextCursor cursor(&doc);
QTextCharFormat cf;
bool bold = false, monospace = false, italic = false, crossed = false,
underline = false;
QString current;
auto apply = [&]() {
if (!current.isEmpty()) {
cursor.insertText(current, cf);
current.clear();
}
};
for (int i = 0; i < irc.length(); ++i) {
switch (irc[i].unicode()) {
case '\x02':
apply();
bold = !bold;
cf.setFontWeight(bold ? QFont::Bold : QFont::Normal);
break;
case '\x03':
// TODO(p): Decode colours, see xC.
break;
case '\x11':
apply();
cf.setFontFixedPitch((monospace = !monospace));
break;
case '\x1d':
apply();
cf.setFontItalic((italic = !italic));
break;
case '\x1e':
apply();
cf.setFontItalic((crossed = !crossed));
break;
case '\x1f':
apply();
cf.setFontUnderline((underline = !underline));
break;
case '\x0f':
apply();
bold = monospace = italic = crossed = underline = false;
cf = QTextCharFormat();
break;
default:
current += irc[i];
}
}
apply();
return doc.toHtml();
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static QBrush
convert_color(int16_t color)
{
static const uint16_t base16[] = {
0x000, 0x800, 0x080, 0x880, 0x008, 0x808, 0x088, 0xccc,
0x888, 0xf00, 0x0f0, 0xff0, 0x00f, 0xf0f, 0x0ff, 0xfff,
};
if (color < 16) {
uint8_t r = 0xf & (base16[color] >> 8);
uint8_t g = 0xf & (base16[color] >> 4);
uint8_t b = 0xf & (base16[color]);
return QColor(r * 0x11, g * 0x11, b * 0x11);
}
if (color >= 216) {
uint8_t g = 8 + (color - 216) * 10;
return QColor(g, g, g);
}
uint8_t i = color - 16, r = i / 36 >> 0, g = (i / 6 >> 0) % 6, b = i % 6;
return QColor(
!r ? 0 : 55 + 40 * r,
!g ? 0 : 55 + 40 * g,
!b ? 0 : 55 + 40 * b);
}
static void
convert_item_formatting(
Relay::ItemData *item, QTextCharFormat &cf, bool &inverse)
{
if (dynamic_cast<Relay::ItemData_Reset *>(item)) {
cf = QTextCharFormat();
inverse = false;
} else if (dynamic_cast<Relay::ItemData_FlipBold *>(item)) {
if (cf.fontWeight() <= QFont::Normal)
cf.setFontWeight(QFont::Bold);
else
cf.setFontWeight(QFont::Normal);
} else if (dynamic_cast<Relay::ItemData_FlipItalic *>(item)) {
cf.setFontItalic(!cf.fontItalic());
} else if (dynamic_cast<Relay::ItemData_FlipUnderline *>(item)) {
cf.setFontUnderline(!cf.fontUnderline());
} else if (dynamic_cast<Relay::ItemData_FlipCrossedOut *>(item)) {
cf.setFontStrikeOut(!cf.fontStrikeOut());
} else if (dynamic_cast<Relay::ItemData_FlipInverse *>(item)) {
inverse = !inverse;
} else if (dynamic_cast<Relay::ItemData_FlipMonospace *>(item)) {
cf.setFontFixedPitch(!cf.fontFixedPitch());
} else if (auto data = dynamic_cast<Relay::ItemData_FgColor *>(item)) {
if (data->color < 0) {
cf.clearForeground();
} else {
cf.setForeground(convert_color(data->color));
}
} else if (auto data = dynamic_cast<Relay::ItemData_BgColor *>(item)) {
if (data->color < 0) {
cf.clearBackground();
} else {
cf.setBackground(convert_color(data->color));
}
}
}
static std::vector<BufferLineItem>
convert_items(const std::vector<std::unique_ptr<Relay::ItemData>> &items)
{
QTextCharFormat cf;
std::vector<BufferLineItem> result;
bool inverse = false;
for (const auto &it : items) {
auto text = dynamic_cast<Relay::ItemData_Text *>(it.get());
if (!text) {
convert_item_formatting(it.get(), cf, inverse);
continue;
}
auto item_format = cf;
auto item_text = QString::fromStdWString(text->text);
if (inverse) {
auto fg = item_format.foreground();
auto bg = item_format.background();
item_format.setBackground(fg);
item_format.setForeground(bg);
}
convert_links(item_format, item_text, result);
}
return result;
}
// --- Buffer output -----------------------------------------------------------
static BufferLine
convert_buffer_line(Relay::EventData_BufferLine &line)
{
BufferLine self = {};
self.items = convert_items(line.items);
self.is_unimportant = line.is_unimportant;
self.is_highlight = line.is_highlight;
self.rendition = line.rendition;
self.when = line.when;
return self;
}
static void
buffer_print_date_change(
bool &sameline, const QDateTime &last, const QDateTime &current)
{
if (last.date() == current.date())
return;
auto timestamp = current.toString(&"\n"[sameline] +
QLocale::system().dateFormat(QLocale::ShortFormat));
sameline = false;
QTextCharFormat cf;
cf.setFontWeight(QFont::Bold);
textedit_replacesel(g.wBuffer, cf, timestamp);
}
static bool
buffer_reset_selection()
{
auto sb = g.wBuffer->verticalScrollBar();
auto value = sb->value();
g.wBuffer->moveCursor(QTextCursor::End);
sb->setValue(value);
return g.wBuffer->textCursor().atBlockStart();
}
static void
buffer_print_and_watch_trailing_date_changes()
{
auto current = QDateTime::currentDateTime();
auto b = buffer_by_name(g.buffer_current);
if (b && !b->lines.empty()) {
auto last = QDateTime::fromMSecsSinceEpoch(b->lines.back().when);
bool sameline = buffer_reset_selection();
buffer_print_date_change(sameline, last, current);
}
QDateTime midnight(current.date().addDays(1), {});
if (midnight < current)
return;
// Note that after printing the first trailing update,
// follow-up updates may be duplicated if timer events arrive too early.
g.date_change_timer->start(current.msecsTo(midnight) + 1);
}
static void
buffer_print_line(std::vector<BufferLine>::const_iterator begin,
std::vector<BufferLine>::const_iterator line)
{
auto current = QDateTime::fromMSecsSinceEpoch(line->when);
auto last = line == begin ? QDateTime::currentDateTime()
: QDateTime::fromMSecsSinceEpoch((line - 1)->when);
bool sameline = buffer_reset_selection();
buffer_print_date_change(sameline, last, current);
auto timestamp = current.toString(&"\nHH:mm:ss"[sameline]);
sameline = false;
QTextCharFormat cf;
cf.setForeground(QColor(0xbb, 0xbb, 0xbb));
cf.setBackground(QColor(0xf8, 0xf8, 0xf8));
textedit_replacesel(g.wBuffer, cf, timestamp);
cf = QTextCharFormat();
textedit_replacesel(g.wBuffer, cf, " ");
// Tabstops won't quite help us here, since we need it centred.
QString prefix;
QTextCharFormat pcf;
pcf.setFontFixedPitch(true);
pcf.setFontWeight(QFont::Bold);
switch (line->rendition) {
break; case Relay::Rendition::BARE:
break; case Relay::Rendition::INDENT:
prefix = " ";
break; case Relay::Rendition::STATUS:
prefix = " - ";
break; case Relay::Rendition::ERROR:
prefix = "=!= ";
pcf.setForeground(QColor(0xff, 0, 0));
break; case Relay::Rendition::JOIN:
prefix = "——> ";
pcf.setForeground(QColor(0, 0x88, 0));
break; case Relay::Rendition::PART:
prefix = "<—— ";
pcf.setForeground(QColor(0x88, 0, 0));
break; case Relay::Rendition::ACTION:
prefix = " * ";
pcf.setForeground(QColor(0x88, 0, 0));
}
if (line->leaked) {
auto color = g.wBuffer->palette().color(
QPalette::Disabled, QPalette::Text);
pcf.setForeground(color);
if (!prefix.isEmpty()) {
textedit_replacesel(g.wBuffer, pcf, prefix);
}
for (auto it : line->items) {
it.format.setForeground(color);
it.format.clearBackground();
textedit_replacesel(g.wBuffer, it.format, it.text);
}
} else {
if (!prefix.isEmpty())
textedit_replacesel(g.wBuffer, pcf, prefix);
for (const auto &it : line->items)
textedit_replacesel(g.wBuffer, it.format, it.text);
}
}
static void
buffer_print_separator()
{
buffer_reset_selection();
QTextFrameFormat ff;
ff.setBackground(QColor(0xff, 0x5f, 0x00));
ff.setHeight(1);
// FIXME: When the current frame was empty, this seems to add a newline.
g.wBuffer->textCursor().insertFrame(ff);
}
static void
refresh_buffer(const Buffer &b)
{
g.wBuffer->clear();
size_t i = 0, mark_before = b.lines.size() -
b.new_messages - b.new_unimportant_messages;
for (auto line = b.lines.begin(); line != b.lines.end(); ++line) {
if (i == mark_before)
buffer_print_separator();
if (!line->is_unimportant || !b.hide_unimportant)
buffer_print_line(b.lines.begin(), line);
i++;
}
buffer_print_and_watch_trailing_date_changes();
buffer_scroll_to_bottom();
// TODO(p): recheck_highlighted() here, or do we handle enough signals?
}
// --- Event processing --------------------------------------------------------
static void
relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m)
{
// Initial sync: skip all other processing, let highlights be.
auto bc = buffer_by_name(g.buffer_current);
if (!bc) {
b.lines.push_back(convert_buffer_line(m));
return;
}
// Retained mode is complicated.
bool display = (!m.is_unimportant || !bc->hide_unimportant) &&
(b.buffer_name == g.buffer_current || m.leak_to_active);
bool to_bottom = display && buffer_at_bottom();
bool visible = display &&
to_bottom &&
!g.wMain->isMinimized() &&
!g.wLog->isVisible();
bool separate = display &&
!visible && !bc->new_messages && !bc->new_unimportant_messages;
auto line = b.lines.insert(b.lines.end(), convert_buffer_line(m));
if (!(visible || m.leak_to_active) ||
b.new_messages || b.new_unimportant_messages) {
if (line->is_unimportant || m.leak_to_active)
b.new_unimportant_messages++;
else
b.new_messages++;
}
if (m.leak_to_active) {
auto line = bc->lines.insert(bc->lines.end(), convert_buffer_line(m));
line->leaked = true;
if (!visible || bc->new_messages || bc->new_unimportant_messages) {
if (line->is_unimportant)
bc->new_unimportant_messages++;
else
bc->new_messages++;
}
}
if (separate)
buffer_print_separator();
if (display)
buffer_print_line(bc->lines.begin(), bc->lines.end() - 1);
if (to_bottom)
buffer_scroll_to_bottom();
if (line->is_highlight || (!visible && !line->is_unimportant &&
b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) {
beep();
if (!visible) {
b.highlighted = true;
refresh_icon();
}
}
refresh_buffer_list();
}
static void
relay_process_callbacks(uint32_t command_seq,
const std::wstring& error, const Relay::ResponseData *response)
{
auto &callbacks = g.command_callbacks;
auto handler = callbacks.find(command_seq);
if (handler == callbacks.end()) {
// TODO(p): Warn about an unawaited response.
} else {
if (handler->second)
handler->second(error, response);
callbacks.erase(handler);
}
// We don't particularly care about wraparound issues.
while (!callbacks.empty() && callbacks.begin()->first <= command_seq) {
auto front = callbacks.begin();
if (front->second)
front->second(L"No response", nullptr);
callbacks.erase(front);
}
}
static void
relay_process_message(const Relay::EventMessage &m)
{
switch (m.data->event) {
case Relay::Event::ERROR:
{
auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get());
relay_process_callbacks(data->command_seq, data->error, nullptr);
break;
}
case Relay::Event::RESPONSE:
{
auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get());
relay_process_callbacks(data->command_seq, {}, data->data.get());
break;
}
case Relay::Event::PING:
{
auto pong = new Relay::CommandData_PingResponse();
pong->event_seq = m.event_seq;
relay_send(pong);
break;
}
case Relay::Event::BUFFER_LINE:
{
auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
relay_process_buffer_line(*b, data);
break;
}
case Relay::Event::BUFFER_UPDATE:
{
auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b) {
b = &*g.buffers.insert(g.buffers.end(), Buffer());
b->buffer_name = QString::fromStdWString(data.buffer_name);
auto item = new QListWidgetItem;
refresh_buffer_list_item(item, *b);
g.wBufferList->addItem(item);
}
bool hiding_toggled = b->hide_unimportant != data.hide_unimportant;
b->hide_unimportant = data.hide_unimportant;
b->kind = data.context->kind;
b->server_name.clear();
if (auto context = dynamic_cast<Relay::BufferContext_Server *>(
data.context.get()))
b->server_name = QString::fromStdWString(context->server_name);
if (auto context = dynamic_cast<Relay::BufferContext_Channel *>(
data.context.get())) {
b->server_name = QString::fromStdWString(context->server_name);
b->modes = QString::fromStdWString(context->modes);
b->topic = convert_items(context->topic);
}
if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>(
data.context.get()))
b->server_name = QString::fromStdWString(context->server_name);
if (b->buffer_name == g.buffer_current) {
refresh_topic(b->topic);
refresh_status();
if (hiding_toggled)
refresh_buffer(*b);
}
break;
}
case Relay::Event::BUFFER_STATS:
{
auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
b->new_messages = data.new_messages;
b->new_unimportant_messages = data.new_unimportant_messages;
b->highlighted = data.highlighted;
refresh_icon();
refresh_buffer_list();
break;
}
case Relay::Event::BUFFER_RENAME:
{
auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
auto original = b->buffer_name;
b->buffer_name = QString::fromStdWString(data.new_);
if (original == g.buffer_current) {
g.buffer_current = b->buffer_name;
refresh_status();
}
refresh_buffer_list();
if (original == g.buffer_last)
g.buffer_last = b->buffer_name;
break;
}
case Relay::Event::BUFFER_REMOVE:
{
auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
int index = b - g.buffers.data();
delete g.wBufferList->takeItem(index);
g.buffers.erase(g.buffers.begin() + index);
refresh_icon();
break;
}
case Relay::Event::BUFFER_ACTIVATE:
{
auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data);
Buffer *old = buffer_by_name(g.buffer_current);
g.buffer_last = g.buffer_current;
g.buffer_current = QString::fromStdWString(data.buffer_name);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
if (old) {
old->new_messages = 0;
old->new_unimportant_messages = 0;
old->highlighted = false;
old->input = g.wInput->toHtml();
old->input_start = g.wInput->textCursor().selectionStart();
old->input_end = g.wInput->textCursor().selectionEnd();
// Note that we effectively overwrite the newest line
// with the current textarea contents, and jump there.
old->history_at = old->history.size();
}
if (g.wLog->isVisible())
buffer_toggle_log();
if (!g.wMain->isMinimized())
b->highlighted = false;
auto item = g.wBufferList->item(b - g.buffers.data());
refresh_buffer_list_item(item, *b);
g.wBufferList->setCurrentItem(item);
refresh_icon();
refresh_topic(b->topic);
refresh_buffer(*b);
refresh_prompt();
refresh_status();
g.wInput->setHtml(b->input);
g.wInput->textCursor().setPosition(b->input_start);
g.wInput->textCursor().setPosition(
b->input_end, QTextCursor::KeepAnchor);
g.wInput->setFocus();
break;
}
case Relay::Event::BUFFER_INPUT:
{
auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
if (b->history_at == b->history.size())
b->history_at++;
b->history.push_back(
irc_to_rich_text(QString::fromStdWString(data.text)));
break;
}
case Relay::Event::BUFFER_CLEAR:
{
auto &data = dynamic_cast<Relay::EventData_BufferClear &>(*m.data);
auto b = buffer_by_name(data.buffer_name);
if (!b)
break;
b->lines.clear();
if (b->buffer_name == g.buffer_current)
refresh_buffer(*b);
break;
}
case Relay::Event::SERVER_UPDATE:
{
auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data);
auto name = QString::fromStdWString(data.server_name);
if (!g.servers.count(name))
g.servers.emplace(name, Server());
auto &server = g.servers.at(name);
server.state = data.data->state;
server.user.clear();
server.user_modes.clear();
if (auto registered = dynamic_cast<Relay::ServerData_Registered *>(
data.data.get())) {
server.user = QString::fromStdWString(registered->user);
server.user_modes = QString::fromStdWString(registered->user_modes);
}
refresh_prompt();
break;
}
case Relay::Event::SERVER_RENAME:
{
auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data);
auto original = QString::fromStdWString(data.server_name);
g.servers.insert_or_assign(
QString::fromStdWString(data.new_), g.servers.at(original));
g.servers.erase(original);
break;
}
case Relay::Event::SERVER_REMOVE:
{
auto &data = dynamic_cast<Relay::EventData_ServerRemove &>(*m.data);
auto name = QString::fromStdWString(data.server_name);
g.servers.erase(name);
break;
}
}
}
// --- Networking --------------------------------------------------------------
static void
relay_show_dialog()
{
g.wConnectHost->setText(g.host);
g.wConnectPort->setText(g.port);
g.wConnectDialog->move(
g.wMain->frameGeometry().center() - g.wConnectDialog->rect().center());
switch (g.wConnectDialog->exec()) {
case QDialog::Accepted:
g.host = g.wConnectHost->text();
g.port = g.wConnectPort->text();
g.socket->connectToHost(g.host, g.port.toUShort());
break;
case QDialog::Rejected:
QCoreApplication::exit();
}
}
static void
relay_process_error([[maybe_unused]] QAbstractSocket::SocketError err)
{
show_error_message(g.socket->errorString());
g.socket->abort();
QTimer::singleShot(0, relay_show_dialog);
}
static void
relay_process_connected()
{
g.command_seq = 0;
g.command_callbacks.clear();
g.buffers.clear();
g.buffer_current.clear();
g.buffer_last.clear();
g.servers.clear();
refresh_icon();
refresh_topic({});
g.wBufferList->clear();
g.wBuffer->clear();
refresh_prompt();
refresh_status();
auto hello = new Relay::CommandData_Hello();
hello->version = Relay::VERSION;
relay_send(hello);
}
static bool
relay_process_buffer(QString &error)
{
// How I wish I could access the internal read buffer directly.
auto s = g.socket;
union {
uint32_t frame_len = 0;
char buf[sizeof frame_len];
};
while (s->peek(buf, sizeof buf) == sizeof buf) {
frame_len = qFromBigEndian(frame_len);
if (s->bytesAvailable() < qint64(sizeof frame_len + frame_len))
break;
s->skip(sizeof frame_len);
auto b = s->read(frame_len);
LibertyXDR::Reader r;
r.data = reinterpret_cast<const uint8_t *>(b.data());
r.length = b.size();
Relay::EventMessage m = {};
if (!m.deserialize(r) || r.length) {
error = "Deserialization failed.";
return false;
}
relay_process_message(m);
}
return true;
}
static void
relay_process_ready()
{
QString err;
if (!relay_process_buffer(err)) {
show_error_message(err);
g.socket->abort();
QTimer::singleShot(0, relay_show_dialog);
}
}
// --- Input line --------------------------------------------------------------
static void
input_set_contents(const QString &input)
{
g.wInput->setHtml(input);
auto cursor = g.wInput->textCursor();
cursor.movePosition(QTextCursor::End);
g.wInput->setTextCursor(cursor);
g.wInput->ensureCursorVisible();
}
static bool
input_submit()
{
auto b = buffer_by_name(g.buffer_current);
if (!b)
return false;
auto input = new Relay::CommandData_BufferInput();
input->buffer_name = b->buffer_name.toStdWString();
input->text = rich_text_to_irc(g.wInput).toStdWString();
// Buffer::history[Buffer::history.size()] is virtual,
// and is represented either by edit contents when it's currently
// being edited, or by Buffer::input in all other cases.
b->history.push_back(g.wInput->toHtml());
b->history_at = b->history.size();
input_set_contents("");
relay_send(input);
return true;
}
struct InputStamp {
int start = {};
int end = {};
QString input;
};
static InputStamp
input_stamp()
{
// Hopefully, the selection markers match the plain text characters.
auto start = g.wInput->textCursor().selectionStart();
auto end = g.wInput->textCursor().selectionEnd();
return {start, end, g.wInput->toPlainText()};
}
static void
input_complete(const InputStamp &state, const std::wstring &error,
const Relay::ResponseData_BufferComplete *response)
{
if (!response) {
show_error_message(QString::fromStdWString(error));
return;
}
auto utf8 = state.input.sliced(0, state.start).toUtf8();
auto preceding = QString(utf8.sliced(0, response->start));
if (response->completions.size() > 0) {
auto insert = response->completions.at(0);
if (response->completions.size() == 1)
insert += L" ";
auto cursor = g.wInput->textCursor();
cursor.setPosition(preceding.length());
cursor.setPosition(state.end, QTextCursor::KeepAnchor);
cursor.insertHtml(irc_to_rich_text(QString::fromStdWString(insert)));
}
if (response->completions.size() != 1)
beep();
// TODO(p): Show all completion options.
}
static bool
input_complete()
{
// TODO(p): Also add an increasing counter to the stamp.
auto state = input_stamp();
if (state.start != state.end)
return false;
auto utf8 = state.input.sliced(0, state.start).toUtf8();
auto complete = new Relay::CommandData_BufferComplete();
complete->buffer_name = g.buffer_current.toStdWString();
complete->text = state.input.toStdWString();
complete->position = utf8.size();
relay_send(complete, [state](auto error, auto response) {
auto stamp = input_stamp();
if (std::make_tuple(stamp.start, stamp.end, stamp.input) !=
std::make_tuple(state.start, state.end, state.input))
return;
input_complete(stamp, error,
dynamic_cast<const Relay::ResponseData_BufferComplete *>(response));
});
return true;
}
static bool
input_up()
{
auto b = buffer_by_name(g.buffer_current);
if (!b || b->history_at < 1)
return false;
if (b->history_at == b->history.size())
b->input = g.wInput->toHtml();
input_set_contents(b->history.at(--b->history_at));
return true;
}
static bool
input_down()
{
auto b = buffer_by_name(g.buffer_current);
if (!b || b->history_at >= b->history.size())
return false;
input_set_contents(++b->history_at == b->history.size()
? b->input
: b->history.at(b->history_at));
return true;
}
class InputEdit : public QTextEdit {
Q_OBJECT
public:
explicit InputEdit(QWidget *parent = nullptr) : QTextEdit(parent) {}
void keyPressEvent(QKeyEvent *event) override
{
auto scrollable = g.wLog->isVisible()
? g.wLog->verticalScrollBar()
: g.wBuffer->verticalScrollBar();
QKeyCombination combo(
event->modifiers() & ~Qt::KeypadModifier, Qt::Key(event->key()));
switch (combo.toCombined()) {
case Qt::Key_Return:
case Qt::Key_Enter:
input_submit();
break;
case QKeyCombination(Qt::ShiftModifier, Qt::Key_Return).toCombined():
case QKeyCombination(Qt::ShiftModifier, Qt::Key_Enter).toCombined():
// Qt amazingly inserts U+2028 LINE SEPARATOR instead.
this->textCursor().insertText("\n");
break;
case Qt::Key_Tab:
input_complete();
break;
case QKeyCombination(Qt::AltModifier, Qt::Key_P).toCombined():
case Qt::Key_Up:
input_up();
break;
case QKeyCombination(Qt::AltModifier, Qt::Key_N).toCombined():
case Qt::Key_Down:
input_down();
break;
case Qt::Key_PageUp:
scrollable->setValue(scrollable->value() - scrollable->pageStep());
break;
case Qt::Key_PageDown:
scrollable->setValue(scrollable->value() + scrollable->pageStep());
break;
default:
QTextEdit::keyPressEvent(event);
return;
}
event->accept();
}
};
// --- General UI --------------------------------------------------------------
class BufferEdit : public QTextBrowser {
Q_OBJECT
public:
explicit BufferEdit(QWidget *parent = nullptr) : QTextBrowser(parent) {}
void resizeEvent(QResizeEvent *event) override
{
bool to_bottom = buffer_at_bottom();
QTextBrowser::resizeEvent(event);
if (to_bottom) {
buffer_scroll_to_bottom();
} else {
recheck_highlighted();
refresh_status();
}
}
};
static void
build_main_window()
{
g.wMain = new QMainWindow;
refresh_icon();
auto central = new QWidget(g.wMain);
auto vbox = new QVBoxLayout(central);
vbox->setContentsMargins(4, 4, 4, 4);
g.wTopic = new QLabel(central);
g.wTopic->setTextFormat(Qt::RichText);
vbox->addWidget(g.wTopic);
auto splitter = new QSplitter(Qt::Horizontal, central);
splitter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
g.wBufferList = new QListWidget(splitter);
g.wBufferList->setSizePolicy(
QSizePolicy::Preferred, QSizePolicy::Expanding);
QObject::connect(g.wBufferList, &QListWidget::currentRowChanged,
[](int row) {
if (row >= 0 && (size_t) row < g.buffers.size())
buffer_activate(g.buffers.at(row).buffer_name);
});
g.wStack = new QStackedWidget(splitter);
g.wBuffer = new BufferEdit(g.wStack);
g.wBuffer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
g.wBuffer->setReadOnly(true);
g.wBuffer->setTextInteractionFlags(
Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
g.wBuffer->setOpenExternalLinks(true);
QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::valueChanged,
[]([[maybe_unused]] int value) {
recheck_highlighted();
refresh_status();
});
QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::rangeChanged,
[]([[maybe_unused]] int min, [[maybe_unused]] int max) {
recheck_highlighted();
refresh_status();
});
g.wLog = new QTextBrowser(g.wStack);
g.wLog->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
g.wLog->setReadOnly(true);
g.wLog->setTextInteractionFlags(
Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
g.wLog->setOpenExternalLinks(true);
g.wStack->addWidget(g.wBuffer);
g.wStack->addWidget(g.wLog);
splitter->addWidget(g.wBufferList);
splitter->setStretchFactor(0, 1);
splitter->addWidget(g.wStack);
splitter->setStretchFactor(1, 2);
vbox->addWidget(splitter);
auto hbox = new QHBoxLayout();
g.wPrompt = new QLabel(central);
hbox->addWidget(g.wPrompt);
g.wButtonB = new QToolButton(central);
g.wButtonB->setText("&B");
g.wButtonB->setCheckable(true);
hbox->addWidget(g.wButtonB);
g.wButtonI = new QToolButton(central);
g.wButtonI->setText("&I");
g.wButtonI->setCheckable(true);
hbox->addWidget(g.wButtonI);
g.wButtonU = new QToolButton(central);
g.wButtonU->setText("&U");
g.wButtonU->setCheckable(true);
hbox->addWidget(g.wButtonU);
g.wStatus = new QLabel(central);
g.wStatus->setAlignment(
Qt::AlignRight | Qt::AlignTrailing | Qt::AlignVCenter);
hbox->addWidget(g.wStatus);
g.wButtonLog = new QToolButton(central);
g.wButtonLog->setText("&Log");
g.wButtonLog->setCheckable(true);
QObject::connect(g.wButtonLog, &QToolButton::clicked,
[]([[maybe_unused]] bool checked) { buffer_toggle_log(); });
hbox->addWidget(g.wButtonLog);
g.wButtonDown = new QToolButton(central);
g.wButtonDown->setIcon(
QApplication::style()->standardIcon(QStyle::SP_ArrowDown));
g.wButtonDown->setToolButtonStyle(Qt::ToolButtonIconOnly);
QObject::connect(g.wButtonDown, &QToolButton::clicked,
[]([[maybe_unused]] bool checked) { buffer_scroll_to_bottom(); });
hbox->addWidget(g.wButtonDown);
vbox->addLayout(hbox);
g.wInput = new InputEdit(central);
g.wInput->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
g.wInput->setMaximumHeight(50);
vbox->addWidget(g.wInput);
// TODO(p): Figure out why this is not reliable.
QObject::connect(g.wInput, &QTextEdit::currentCharFormatChanged,
[](const QTextCharFormat &format) {
g.wButtonB->setChecked(format.fontWeight() >= QFont::Bold);
g.wButtonI->setChecked(format.fontItalic());
g.wButtonU->setChecked(format.fontUnderline());
});
QObject::connect(g.wButtonB, &QToolButton::clicked,
[](bool checked) {
auto cursor = g.wInput->textCursor();
auto format = cursor.charFormat();
format.setFontWeight(checked ? QFont::Bold : QFont::Normal);
cursor.mergeCharFormat(format);
g.wInput->setTextCursor(cursor);
});
QObject::connect(g.wButtonI, &QToolButton::clicked,
[](bool checked) {
auto cursor = g.wInput->textCursor();
auto format = cursor.charFormat();
format.setFontItalic(checked);
cursor.mergeCharFormat(format);
g.wInput->setTextCursor(cursor);
});
QObject::connect(g.wButtonU, &QToolButton::clicked,
[](bool checked) {
auto cursor = g.wInput->textCursor();
auto format = cursor.charFormat();
format.setFontUnderline(checked);
cursor.mergeCharFormat(format);
g.wInput->setTextCursor(cursor);
});
central->setLayout(vbox);
g.wMain->setCentralWidget(central);
g.wMain->show();
}
static void
build_connect_dialog()
{
auto dialog = g.wConnectDialog = new QDialog(g.wMain);
dialog->setModal(true);
dialog->setWindowTitle("Connect to relay");
auto layout = new QFormLayout();
g.wConnectHost = new QLineEdit(dialog);
layout->addRow("&Host:", g.wConnectHost);
g.wConnectPort = new QLineEdit(dialog);
auto validator = new QIntValidator(0, 0xffff, g.wConnectDialog);
g.wConnectPort->setValidator(validator);
layout->addRow("&Port:", g.wConnectPort);
auto buttons = new QDialogButtonBox(dialog);
buttons->addButton(new QPushButton("&Connect", buttons),
QDialogButtonBox::AcceptRole);
buttons->addButton(new QPushButton("&Exit", buttons),
QDialogButtonBox::RejectRole);
QObject::connect(buttons, &QDialogButtonBox::accepted,
dialog, &QDialog::accept);
QObject::connect(buttons, &QDialogButtonBox::rejected,
dialog, &QDialog::reject);
auto vbox = new QVBoxLayout();
vbox->addLayout(layout);
vbox->addWidget(buttons);
dialog->setLayout(vbox);
}
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
static std::vector<size_t>
rotated_buffers()
{
std::vector<size_t> rotated(g.buffers.size());
size_t start = 0;
for (auto it = g.buffers.begin(); it != g.buffers.end(); ++it)
if (it->buffer_name == g.buffer_current) {
start = it - g.buffers.begin();
break;
}
for (auto &index : rotated)
index = ++start % g.buffers.size();
return rotated;
}
static void
bind_shortcuts()
{
auto previous_buffer = [] {
auto rotated = rotated_buffers();
if (rotated.size() > 0) {
size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1;
buffer_activate(g.buffers[i].buffer_name);
}
};
auto next_buffer = [] {
auto rotated = rotated_buffers();
if (rotated.size() > 0)
buffer_activate(g.buffers[rotated.front()].buffer_name);
};
auto switch_buffer = [] {
if (auto b = buffer_by_name(g.buffer_last))
buffer_activate(b->buffer_name);
};
auto goto_highlight = [] {
for (auto i : rotated_buffers())
if (g.buffers[i].highlighted) {
buffer_activate(g.buffers[i].buffer_name);
break;
}
};
auto goto_activity = [] {
for (auto i : rotated_buffers())
if (g.buffers[i].new_messages) {
buffer_activate(g.buffers[i].buffer_name);
break;
}
};
auto toggle_unimportant = [] {
if (auto b = buffer_by_name(g.buffer_current))
buffer_toggle_unimportant(b->buffer_name);
};
new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_Tab),
g.wMain, switch_buffer);
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Tab),
g.wMain, switch_buffer);
new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F5),
g.wMain, previous_buffer);
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageUp),
g.wMain, previous_buffer);
new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageUp),
g.wMain, previous_buffer);
new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F6),
g.wMain, next_buffer);
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageDown),
g.wMain, next_buffer);
new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageDown),
g.wMain, next_buffer);
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_A),
g.wMain, goto_activity);
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Exclam),
g.wMain, goto_highlight);
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_H),
g.wMain, toggle_unimportant);
}
int
main(int argc, char *argv[])
{
QApplication app(argc, argv);
auto args = app.arguments();
if (args.size() != 1 && args.size() != 3) {
QMessageBox::critical(nullptr, "Error", "Usage: xT [HOST PORT]",
QMessageBox::Close);
return 1;
}
build_main_window();
build_connect_dialog();
bind_shortcuts();
g.date_change_timer = new QTimer(g.wMain);
g.date_change_timer->setSingleShot(true);
QObject::connect(g.date_change_timer, &QTimer::timeout, [] {
bool to_bottom = buffer_at_bottom();
buffer_print_and_watch_trailing_date_changes();
if (to_bottom)
buffer_scroll_to_bottom();
});
g.socket = new QTcpSocket(g.wMain);
QObject::connect(g.socket, &QTcpSocket::errorOccurred,
relay_process_error);
QObject::connect(g.socket, &QTcpSocket::connected,
relay_process_connected);
QObject::connect(g.socket, &QTcpSocket::readyRead,
relay_process_ready);
if (args.size() == 3) {
g.host = args[1];
g.port = args[2];
g.socket->connectToHost(g.host, g.port.toUShort());
} else {
// Allow it to center on its parent, which must be realized.
while (!g.wMain->windowHandle()->isExposed())
app.processEvents();
QTimer::singleShot(0, relay_show_dialog);
}
int result = app.exec();
delete g.wMain;
return result;
}
// Normally, QObjects should be placed in header files, which we don't do.
#include "xT.moc"