diff options
| author | Přemysl Eric Janouch <p@janouch.name> | 2026-01-03 15:56:36 +0100 |
|---|---|---|
| committer | Přemysl Eric Janouch <p@janouch.name> | 2026-01-03 17:50:06 +0100 |
| commit | e6eaa3f4fa6de21b6e097da1314940c01dfb8ac9 (patch) | |
| tree | 7a46671524165f2b9dce31e0d38279f0c35daa28 | |
| parent | 21c3fdab6c786a150f027c5c7506258c94e75330 (diff) | |
| download | xK-master.tar.gz xK-master.tar.xz xK-master.zip | |
| -rw-r--r-- | xT/xTq.cpp | 1224 | ||||
| -rw-r--r-- | xT/xTq.h | 216 | ||||
| -rw-r--r-- | xT/xTq.qml | 413 |
3 files changed, 1814 insertions, 39 deletions
@@ -17,15 +17,1231 @@ */ #include "xC-proto.cpp" +#include "xTq.h" -#include <cstdint> - +#include <QColor> +#include <QDateTime> #include <QGuiApplication> +#include <QLocale> #include <QQmlApplicationEngine> +#include <QQuickTextDocument> +#include <QRegularExpression> +#include <QTextBlock> +#include <QTextCursor> +#include <QTextDocument> +#include <QtDebug> +#include <QtEndian> +#include <QDateTime> +#include <QGuiApplication> +#include <QLocale> +#include <QQmlApplicationEngine> +#include <QRegularExpression> +#include <QTextBlock> +#include <QTextCursor> +#include <QTextDocument> +#include <QtDebug> +#include <QtEndian> -#include "xTq.h" +// --- IRC formatting utilities ------------------------------------------------ + +static QColor +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)) { + auto families = cf.fontFamilies().toStringList(); + if (!families.isEmpty() && families.first() == "monospace") + cf.setFontFamilies(QStringList()); + else + cf.setFontFamilies(QStringList() << "monospace"); + } 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 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 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; +} + +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(); + monospace = !monospace; + if (monospace) + cf.setFontFamilies(QStringList() << "monospace"); + else + cf.setFontFamilies(QStringList()); + break; + case '\x1d': + apply(); + cf.setFontItalic((italic = !italic)); + break; + case '\x1e': + apply(); + cf.setFontStrikeOut((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 QString +rich_text_to_irc(const QString &html) +{ + QTextDocument doc; + doc.setHtml(html); + + QString irc; + for (auto block = doc.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; +} + +// --- BufferListModel --------------------------------------------------------- + +BufferListModel::BufferListModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +int +BufferListModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid() || !buffers_) + return 0; + return buffers_->size(); +} + +QVariant +BufferListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || !buffers_ || + index.row() < 0 || index.row() >= int(buffers_->size())) + return {}; + + const auto &buffer = buffers_->at(index.row()); + switch (role) { + case BufferNameRole: + return buffer.buffer_name; + case BufferKindRole: + return int(buffer.kind); + case ServerNameRole: + return buffer.server_name; + case NewMessagesRole: + return buffer.new_messages; + case HighlightedRole: + return buffer.highlighted; + case DisplayTextRole: + { + auto text = buffer.buffer_name; + if (buffer.buffer_name != current && buffer.new_messages) + text += " (" + QString::number(buffer.new_messages) + ")"; + return text; + } + case IsBoldRole: + return buffer.buffer_name != current && buffer.new_messages > 0; + case HighlightColorRole: + return buffer.highlighted ? QColor(0xff, 0x5f, 0x00) : QColor(); + } + return {}; +} + +QHash<int, QByteArray> +BufferListModel::roleNames() const +{ + return { + {BufferNameRole, "bufferName"}, + {BufferKindRole, "bufferKind"}, + {ServerNameRole, "serverName"}, + {NewMessagesRole, "newMessages"}, + {HighlightedRole, "highlighted"}, + {DisplayTextRole, "displayText"}, + {IsBoldRole, "isBold"}, + {HighlightColorRole, "highlightColor"} + }; +} + +void +BufferListModel::setBuffers(const std::vector<Buffer> *buffers) +{ + beginResetModel(); + buffers_ = buffers; + endResetModel(); +} + +void +BufferListModel::setCurrentBuffer(const QString ¤t_) +{ + current = current_; + emit bufferActivated(current_); +} + +int +BufferListModel::getCurrentBufferIndex() const +{ + if (!buffers_) + return -1; + + for (size_t i = 0; i < buffers_->size(); i++) { + if (buffers_->at(i).buffer_name == current) + return i; + } + return -1; +} + +void +BufferListModel::refresh() +{ + if (!buffers_) + return; + dataChanged(index(0), index(buffers_->size() - 1)); +} + +void +BufferListModel::refreshBuffer(int idx) +{ + if (!buffers_ || idx < 0 || idx >= int(buffers_->size())) + return; + auto i = index(idx); + dataChanged(i, i); +} + +void +BufferListModel::bufferAdded(int idx) +{ + if (!buffers_) + return; + beginInsertRows(QModelIndex(), idx, idx); + endInsertRows(); +} + +void +BufferListModel::bufferRemoved(int idx) +{ + if (!buffers_) + return; + beginRemoveRows(QModelIndex(), idx, idx); + endRemoveRows(); +} + +// --- RelayConnection --------------------------------------------------------- + +RelayConnection::RelayConnection(QObject *parent) + : QObject(parent), + socket(new QTcpSocket(this)), + date_change_timer(new QTimer(this)) +{ + buffer_list_model.setBuffers(&buffers); + + connect(socket, &QTcpSocket::connected, + this, &RelayConnection::onSocketConnected); + connect(socket, &QTcpSocket::disconnected, + this, &RelayConnection::onSocketDisconnected); + connect(socket, &QTcpSocket::readyRead, + this, &RelayConnection::onSocketReadyRead); + connect(socket, &QTcpSocket::errorOccurred, + this, &RelayConnection::onSocketError); + + connect(date_change_timer, &QTimer::timeout, [this]() { + // Update buffer display to show new date marker + emit bufferTextChanged(); + + // Schedule next update at midnight + auto current = QDateTime::currentDateTime(); + QDateTime midnight(current.date().addDays(1), {}); + if (midnight > current) + date_change_timer->start(current.msecsTo(midnight) + 1); + }); +} + +void +RelayConnection::setHost(const QString &host) +{ + if (host_ != host) { + host_ = host; + emit hostChanged(); + } +} + +void +RelayConnection::setPort(const QString &port) +{ + if (port_ != port) { + port_ = port; + emit portChanged(); + } +} + +bool +RelayConnection::isConnected() const +{ + return socket->state() == QAbstractSocket::ConnectedState; +} + +QString +RelayConnection::prompt() const +{ + auto b = const_cast<RelayConnection *>(this)->bufferByName(buffer_current); + if (!b) { + return "Synchronizing..."; + } else if (auto it = servers.find(b->server_name); it != servers.end()) { + QString prompt = it->second.user; + if (!it->second.user_modes.isEmpty()) + prompt += "(" + it->second.user_modes + ")"; + if (prompt.isEmpty()) { + switch (it->second.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 prompt; + } + return {}; +} + +QString +RelayConnection::status() const +{ + QString status = buffer_current; + if (auto b = const_cast<RelayConnection *>(this)->bufferByName(buffer_current)) { + if (!b->modes.isEmpty()) + status += "(+" + b->modes + ")"; + if (b->hide_unimportant) + status += "<H>"; + } + return status; +} + +QString +RelayConnection::topic() const +{ + auto b = const_cast<RelayConnection *>(this)->bufferByName(buffer_current); + if (!b) + return {}; + + QTextDocument doc; + QTextCursor cursor(&doc); + for (const auto &it : b->topic) { + cursor.setCharFormat(it.format); + cursor.insertText(it.text); + } + return doc.toHtml(); +} + +void +RelayConnection::connectToRelay() +{ + if (host_.isEmpty() || port_.isEmpty()) { + emit errorOccurred("Host or port not set"); + return; + } + socket->connectToHost(host_, port_.toUShort()); +} + +void +RelayConnection::disconnectFromRelay() +{ + socket->disconnectFromHost(); +} + +void +RelayConnection::activateBuffer(const QString &name) +{ + auto activate = new Relay::CommandData_BufferActivate(); + activate->buffer_name = name.toStdWString(); + relaySend(activate); +} + +void +RelayConnection::activatePreviousBuffer() +{ + if (buffers.empty()) + return; + + int current_index = -1; + for (size_t i = 0; i < buffers.size(); i++) { + if (buffers[i].buffer_name == buffer_current) { + current_index = i; + break; + } + } + + if (current_index > 0) { + activateBuffer(buffers[current_index - 1].buffer_name); + } +} + +void +RelayConnection::activateNextBuffer() +{ + if (buffers.empty()) + return; + + int current_index = -1; + for (size_t i = 0; i < buffers.size(); i++) { + if (buffers[i].buffer_name == buffer_current) { + current_index = i; + break; + } + } + + if (current_index >= 0 && (size_t)current_index < buffers.size() - 1) { + activateBuffer(buffers[current_index + 1].buffer_name); + } +} + +void +RelayConnection::toggleUnimportant() +{ + if (buffer_current.isEmpty()) + return; + + auto toggle = new Relay::CommandData_BufferToggleUnimportant(); + toggle->buffer_name = buffer_current.toStdWString(); + relaySend(toggle); +} + +void +RelayConnection::sendInput(const QString &input) +{ + auto b = bufferByName(buffer_current); + if (!b) + return; + + auto cmd = new Relay::CommandData_BufferInput(); + cmd->buffer_name = b->buffer_name.toStdWString(); + cmd->text = rich_text_to_irc(input).toStdWString(); + + b->history.push_back(input); + b->history_at = b->history.size(); + + relaySend(cmd); +} + +void +RelayConnection::sendCommand(const QString &command) +{ + // For now, just send as input + sendInput(command); +} + +void +RelayConnection::populateBufferDocument(QQuickTextDocument *quickDoc) +{ + if (!quickDoc) + return; + + QTextDocument *doc = quickDoc->textDocument(); + if (!doc) + return; + + auto b = bufferByName(buffer_current); + doc->clear(); + if (!b) + return; + + QTextCursor cursor(doc); + for (const auto &line : b->lines) { + if (line.is_unimportant && b->hide_unimportant) + continue; + + // Timestamp + auto dt = QDateTime::fromMSecsSinceEpoch(line.when); + auto timestamp = dt.toString("HH:mm:ss "); + QTextCharFormat tsFormat; + tsFormat.setForeground(QColor(0xbb, 0xbb, 0xbb)); + tsFormat.setBackground(QColor(0xf8, 0xf8, 0xf8)); + cursor.insertText(timestamp, tsFormat); + + // Rendition prefix + QString prefix; + QTextCharFormat pcf; + pcf.setFontFamilies(QStringList() << "monospace"); + 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 (!prefix.isEmpty()) + cursor.insertText(prefix, pcf); + + // Message items + for (const auto &it : line.items) { + cursor.insertText(it.text, it.format); + } + + // Reset format before inserting newline to prevent color spillage + cursor.setCharFormat(QTextCharFormat()); + cursor.insertText("\n"); + } +} + +QString +RelayConnection::getInputHistoryUp() +{ + auto b = bufferByName(buffer_current); + if (!b || b->history_at < 1) + return {}; + + if (b->history_at == b->history.size()) + b->input = ""; // Current input is being navigated away from + b->history_at--; + return b->history.at(b->history_at); +} + +QString +RelayConnection::getInputHistoryDown() +{ + auto b = bufferByName(buffer_current); + if (!b || b->history_at >= b->history.size()) + return {}; + + b->history_at++; + if (b->history_at == b->history.size()) + return b->input; + return b->history.at(b->history_at); +} + +void +RelayConnection::requestCompletion(const QString &text, int position) +{ + auto b = bufferByName(buffer_current); + if (!b) + return; + + auto utf8 = text.left(position).toUtf8(); + auto complete = new Relay::CommandData_BufferComplete(); + complete->buffer_name = b->buffer_name.toStdWString(); + complete->text = text.toStdWString(); + complete->position = utf8.size(); + + relaySend(complete, [this, text, position](auto error, auto response) { + if (!response) { + emit errorOccurred(QString::fromStdWString(error)); + return; + } + + auto result = dynamic_cast<const Relay::ResponseData_BufferComplete *>(response); + if (!result || result->completions.empty()) + return; + + auto insert = result->completions.at(0); + if (result->completions.size() == 1) + insert += L" "; + + auto preceding = text.left(result->start); + auto completion = preceding + QString::fromStdWString(insert); + emit completionResult(completion); + + if (result->completions.size() != 1) + emit beepRequested(); + }); +} + +void +RelayConnection::saveBufferInput(const QString &bufferName, const QString &input, int start, int end) +{ + auto b = bufferByName(bufferName); + if (!b) + return; + + b->input = input; + b->input_start = start; + b->input_end = end; +} + +QString +RelayConnection::getBufferInput() +{ + auto b = bufferByName(buffer_current); + return b ? b->input : QString(); +} + +int +RelayConnection::getBufferInputStart() +{ + auto b = bufferByName(buffer_current); + return b ? b->input_start : 0; +} + +int +RelayConnection::getBufferInputEnd() +{ + auto b = bufferByName(buffer_current); + return b ? b->input_end : 0; +} + +void +RelayConnection::activateLastBuffer() +{ + if (!buffer_last.isEmpty()) + activateBuffer(buffer_last); +} + +void +RelayConnection::activateNextHighlighted() +{ + int startIdx = -1; + for (size_t i = 0; i < buffers.size(); i++) { + if (buffers[i].buffer_name == buffer_current) { + startIdx = i; + break; + } + } + + if (startIdx < 0) + return; + + for (size_t i = 1; i < buffers.size(); i++) { + size_t idx = (startIdx + i) % buffers.size(); + if (buffers[idx].highlighted) { + activateBuffer(buffers[idx].buffer_name); + return; + } + } +} + +void +RelayConnection::activateNextWithActivity() +{ + int startIdx = -1; + for (size_t i = 0; i < buffers.size(); i++) { + if (buffers[i].buffer_name == buffer_current) { + startIdx = i; + break; + } + } + + if (startIdx < 0) + return; + + for (size_t i = 1; i < buffers.size(); i++) { + size_t idx = (startIdx + i) % buffers.size(); + if (buffers[idx].new_messages > 0) { + activateBuffer(buffers[idx].buffer_name); + return; + } + } +} + +void +RelayConnection::toggleBufferLog() +{ + auto log = new Relay::CommandData_BufferLog(); + log->buffer_name = buffer_current.toStdWString(); + relaySend(log, [this, name = buffer_current](auto error, auto response) { + if (!response) { + emit errorOccurred(QString::fromStdWString(error)); + return; + } + if (buffer_current != name) + return; + + auto result = dynamic_cast<const Relay::ResponseData_BufferLog *>(response); + if (!result) + return; + + std::wstring log; + if (!LibertyXDR::utf8_to_wstring( + result->log.data(), result->log.size(), log)) { + emit errorOccurred("Invalid encoding."); + return; + } + + std::vector<BufferLineItem> linkified; + convert_links({}, QString::fromStdWString(log), linkified); + + QTextDocument doc; + QTextCursor cursor(&doc); + for (const auto &it : linkified) { + cursor.setCharFormat(it.format); + cursor.insertText(it.text); + } + + emit logViewChanged(doc.toHtml()); + }); +} + +void +RelayConnection::onSocketConnected() +{ + command_seq = 0; + command_callbacks.clear(); + + buffers.clear(); + buffer_current.clear(); + buffer_last.clear(); + servers.clear(); + + buffer_list_model.refresh(); + + emit connectedChanged(); + emit currentBufferChanged(); + emit promptChanged(); + emit statusChanged(); + emit topicChanged(); + + auto hello = new Relay::CommandData_Hello(); + hello->version = Relay::VERSION; + relaySend(hello); +} + +void +RelayConnection::onSocketDisconnected() +{ + emit connectedChanged(); +} + +void +RelayConnection::onSocketReadyRead() +{ + union { + uint32_t frame_len = 0; + char buf[sizeof frame_len]; + }; + while (socket->peek(buf, sizeof buf) == sizeof buf) { + frame_len = qFromBigEndian(frame_len); + if (socket->bytesAvailable() < qint64(sizeof frame_len + frame_len)) + break; + + socket->skip(sizeof frame_len); + auto b = socket->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) { + emit errorOccurred("Deserialization failed"); + socket->abort(); + return; + } + + processRelayEvent(m); + } +} + +void +RelayConnection::onSocketError(QAbstractSocket::SocketError error) +{ + Q_UNUSED(error); + emit errorOccurred(socket->errorString()); +} + +void +RelayConnection::relaySend(Relay::CommandData *data, Callback callback) +{ + Relay::CommandMessage m = {}; + m.command_seq = ++command_seq; + m.data.reset(data); + LibertyXDR::Writer w; + m.serialize(w); + + if (callback) + command_callbacks[m.command_seq] = std::move(callback); + else + command_callbacks[m.command_seq] = [this](auto error, auto response) { + if (!response) + emit errorOccurred(QString::fromStdWString(error)); + }; + + 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 (socket->write(prefix, sizeof len) < 0 || + socket->write(mdata, w.data.size()) < 0) { + socket->abort(); + } +} + +void +RelayConnection::processRelayEvent(const Relay::EventMessage &m) +{ + switch (m.data->event) { + case Relay::Event::ERROR: + { + auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get()); + auto &callbacks = command_callbacks; + auto handler = callbacks.find(data->command_seq); + if (handler != callbacks.end()) { + if (handler->second) + handler->second(data->error, nullptr); + callbacks.erase(handler); + } + break; + } + case Relay::Event::RESPONSE: + { + auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get()); + auto &callbacks = command_callbacks; + auto handler = callbacks.find(data->command_seq); + if (handler != callbacks.end()) { + if (handler->second) + handler->second({}, data->data.get()); + callbacks.erase(handler); + } + break; + } + + case Relay::Event::PING: + { + auto pong = new Relay::CommandData_PingResponse(); + pong->event_seq = m.event_seq; + relaySend(pong); + break; + } + + case Relay::Event::BUFFER_LINE: + { + auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data); + auto b = bufferByName(QString::fromStdWString(data.buffer_name)); + if (!b) + break; + + BufferLine line = {}; + line.items = convert_items(data.items); + line.is_unimportant = data.is_unimportant; + line.is_highlight = data.is_highlight; + line.rendition = data.rendition; + line.when = data.when; + + b->lines.push_back(line); + + // Update stats + auto bc = bufferByName(buffer_current); + if (bc) { + if (line.is_unimportant) + b->new_unimportant_messages++; + else + b->new_messages++; + + if (line.is_highlight || (!line.is_unimportant && + b->kind == Relay::BufferKind::PRIVATE_MESSAGE)) { + emit beepRequested(); + b->highlighted = true; + } + } + + buffer_list_model.refresh(); + if (b->buffer_name == buffer_current) { + emit bufferTextChanged(); + } + break; + } + + case Relay::Event::BUFFER_UPDATE: + { + auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data); + auto b = bufferByName(QString::fromStdWString(data.buffer_name)); + if (!b) { + buffers.push_back(Buffer()); + b = &buffers.back(); + b->buffer_name = QString::fromStdWString(data.buffer_name); + buffer_list_model.bufferAdded(buffers.size() - 1); + } + + 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 == buffer_current) { + emit topicChanged(); + emit statusChanged(); + } + break; + } + + case Relay::Event::BUFFER_STATS: + { + auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data); + auto b = bufferByName(QString::fromStdWString(data.buffer_name)); + if (!b) + break; + + b->new_messages = data.new_messages; + b->new_unimportant_messages = data.new_unimportant_messages; + b->highlighted = data.highlighted; + + buffer_list_model.refresh(); + refreshIcon(); + break; + } + + case Relay::Event::BUFFER_RENAME: + { + auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data); + auto b = bufferByName(QString::fromStdWString(data.buffer_name)); + if (!b) + break; + + auto original = b->buffer_name; + b->buffer_name = QString::fromStdWString(data.new_); + + if (original == buffer_current) { + buffer_current = b->buffer_name; + emit currentBufferChanged(); + emit statusChanged(); + } + if (original == buffer_last) + buffer_last = b->buffer_name; + + buffer_list_model.refresh(); + break; + } + + case Relay::Event::BUFFER_REMOVE: + { + auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data); + auto b = bufferByName(QString::fromStdWString(data.buffer_name)); + if (!b) + break; + + int index = b - buffers.data(); + buffer_list_model.bufferRemoved(index); + buffers.erase(buffers.begin() + index); + refreshIcon(); + break; + } + + case Relay::Event::BUFFER_ACTIVATE: + { + auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data); + auto new_buffer = QString::fromStdWString(data.buffer_name); + + // Signal QML to save current buffer's input before switching + emit aboutToChangeBuffer(buffer_current); + + buffer_last = buffer_current; + buffer_current = new_buffer; + auto b = bufferByName(new_buffer); + if (!b) + break; + + // Clear old buffer stats + if (auto old = bufferByName(buffer_last)) { + old->new_messages = 0; + old->new_unimportant_messages = 0; + old->highlighted = false; + } + + b->highlighted = false; + buffer_list_model.setCurrentBuffer(buffer_current); + buffer_list_model.refresh(); + + refreshIcon(); + emit currentBufferChanged(); + emit topicChanged(); + emit promptChanged(); + emit statusChanged(); + break; + } + + case Relay::Event::BUFFER_INPUT: + { + auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data); + auto b = bufferByName(QString::fromStdWString(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 = bufferByName(QString::fromStdWString(data.buffer_name)); + if (!b) + break; + + b->lines.clear(); + if (b->buffer_name == buffer_current) { + emit bufferTextChanged(); + } + break; + } + + case Relay::Event::SERVER_UPDATE: + { + auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data); + auto name = QString::fromStdWString(data.server_name); + if (!servers.count(name)) + servers.emplace(name, Server()); + + auto &server = 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); + } + + emit promptChanged(); + break; + } + + case Relay::Event::SERVER_RENAME: + { + auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data); + auto original = QString::fromStdWString(data.server_name); + servers.insert_or_assign( + QString::fromStdWString(data.new_), servers.at(original)); + 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); + servers.erase(name); + break; + } + } +} + +Buffer * +RelayConnection::bufferByName(const QString &name) +{ + for (auto &b : buffers) + if (b.buffer_name == name) + return &b; + return nullptr; +} + +void +RelayConnection::refreshPrompt() +{ + emit promptChanged(); +} + +void +RelayConnection::refreshStatus() +{ + emit statusChanged(); +} + +void +RelayConnection::refreshTopic() +{ + emit topicChanged(); +} + +void +RelayConnection::refreshIcon() +{ + // Window icon changes would need QML/Window integration + // For now, the icon highlighting is handled via buffer list colors +} + +void +RelayConnection::recheckHighlighted() +{ + // Highlight rechecking when scrolling to bottom + // This would be handled in QML when scroll position changes +} -// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// --- Main -------------------------------------------------------------------- int main(int argc, char *argv[]) @@ -1,15 +1,229 @@ #ifndef XTQ_H #define XTQ_H +#include <cstdint> +#include <cstddef> +#include <functional> +#include <map> +#include <string> +#include <vector> + +#include <QAbstractListModel> +#include <QColor> +#include <QHash> +#include <QObject> +#include <QString> #include <QTcpSocket> +#include <QTextCharFormat> +#include <QTimer> +#include <QVariant> #include <QtQmlIntegration/qqmlintegration.h> +#include <QQuickTextDocument> + +// Forward declarations for Relay protocol +namespace Relay { + class CommandData; + class EventMessage; + class ResponseData; + enum class ServerState : int8_t; + enum class BufferKind : int8_t; + enum class Rendition : int8_t; + class ItemData; +} + +// --- Data structures --------------------------------------------------------- + +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 (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)>; + +// --- Buffer list model ------------------------------------------------------- + +class BufferListModel : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + +public: + enum Roles { + BufferNameRole = Qt::UserRole + 1, + BufferKindRole, + ServerNameRole, + NewMessagesRole, + HighlightedRole, + DisplayTextRole, + IsBoldRole, + HighlightColorRole + }; + + explicit BufferListModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QHash<int, QByteArray> roleNames() const override; + + void setBuffers(const std::vector<Buffer> *buffers); + void setCurrentBuffer(const QString ¤t); + Q_INVOKABLE int getCurrentBufferIndex() const; + void refresh(); + void refreshBuffer(int index); + void bufferAdded(int index); + void bufferRemoved(int index); + +signals: + void bufferActivated(const QString &bufferName); + +private: + const std::vector<Buffer> *buffers_ = nullptr; + QString current; +}; + +// --- Main relay connection --------------------------------------------------- + class RelayConnection : public QObject { Q_OBJECT QML_ELEMENT + Q_PROPERTY(QString host READ host WRITE setHost NOTIFY hostChanged) + Q_PROPERTY(QString port READ port WRITE setPort NOTIFY portChanged) + Q_PROPERTY(bool connected READ isConnected NOTIFY connectedChanged) + Q_PROPERTY(QString currentBuffer READ currentBuffer NOTIFY currentBufferChanged) + Q_PROPERTY(QString prompt READ prompt NOTIFY promptChanged) + Q_PROPERTY(QString status READ status NOTIFY statusChanged) + Q_PROPERTY(QString topic READ topic NOTIFY topicChanged) + Q_PROPERTY(BufferListModel *bufferListModel READ bufferListModel CONSTANT) + public: - QTcpSocket *socket; ///< Buffered relay socket + explicit RelayConnection(QObject *parent = nullptr); + + QString host() const { return host_; } + void setHost(const QString &host); + + QString port() const { return port_; } + void setPort(const QString &port); + + bool isConnected() const; + + QString currentBuffer() const { return buffer_current; } + QString prompt() const; + QString status() const; + QString topic() const; + + BufferListModel *bufferListModel() { return &buffer_list_model; } + + Q_INVOKABLE void connectToRelay(); + Q_INVOKABLE void disconnectFromRelay(); + Q_INVOKABLE void activateBuffer(const QString &name); + Q_INVOKABLE void activatePreviousBuffer(); + Q_INVOKABLE void activateNextBuffer(); + Q_INVOKABLE void toggleUnimportant(); + Q_INVOKABLE void sendInput(const QString &input); + Q_INVOKABLE void sendCommand(const QString &command); + Q_INVOKABLE void populateBufferDocument(QQuickTextDocument *document); + Q_INVOKABLE QString getInputHistoryUp(); + Q_INVOKABLE QString getInputHistoryDown(); + Q_INVOKABLE void requestCompletion(const QString &text, int position); + Q_INVOKABLE void activateLastBuffer(); + Q_INVOKABLE void activateNextHighlighted(); + Q_INVOKABLE void activateNextWithActivity(); + Q_INVOKABLE void toggleBufferLog(); + Q_INVOKABLE void saveBufferInput(const QString &bufferName, const QString &input, int start, int end); + Q_INVOKABLE QString getBufferInput(); + Q_INVOKABLE int getBufferInputStart(); + Q_INVOKABLE int getBufferInputEnd(); + +signals: + void hostChanged(); + void portChanged(); + void connectedChanged(); + void aboutToChangeBuffer(const QString &oldBuffer); + void currentBufferChanged(); + void promptChanged(); + void statusChanged(); + void topicChanged(); + void errorOccurred(const QString &message); + void beepRequested(); + void completionResult(const QString &completion); + void bufferTextChanged(); + void logViewChanged(const QString &logText); + +private slots: + void onSocketConnected(); + void onSocketDisconnected(); + void onSocketReadyRead(); + void onSocketError(QAbstractSocket::SocketError error); + +private: + void relaySend(Relay::CommandData *data, Callback callback = {}); + void processRelayEvent(const Relay::EventMessage &m); + + Buffer *bufferByName(const QString &name); + void refreshPrompt(); + void refreshStatus(); + void refreshTopic(); + void refreshIcon(); + void recheckHighlighted(); + + QTcpSocket *socket; + QString host_; + QString port_; + + uint32_t command_seq = 0; + std::map<uint32_t, Callback> command_callbacks; + + std::vector<Buffer> buffers; + QString buffer_current; + QString buffer_last; + std::map<QString, Server> servers; + + BufferListModel buffer_list_model; + + QTimer *date_change_timer; }; #endif // XTQ_H @@ -1,90 +1,422 @@ import QtQuick import QtQuick.Controls.Fusion -//import QtQuick.Controls import QtQuick.Layouts +import QtQuick.Dialogs +import QtMultimedia ApplicationWindow { id: window - width: 640 - height: 480 + width: 960 + height: 720 visible: true - title: qsTr("xT") + title: qsTr("xTq") - property RelayConnection connection + RelayConnection { + id: connection + + onErrorOccurred: function(message) { + errorDialog.text = message + errorDialog.open() + } + + onBeepRequested: { + beepSound.play() + } + + onConnectedChanged: { + if (!connected) { + connectDialog.open() + } + } + + onAboutToChangeBuffer: function(oldBuffer) { + // Save the current buffer's input before switching + connection.saveBufferInput( + oldBuffer, + inputText.text, + inputText.selectionStart, + inputText.selectionEnd + ) + } + + onBufferTextChanged: { + connection.populateBufferDocument(bufferText.textDocument) + scrollToBottom() + } + + onCompletionResult: function(completion) { + inputText.text = completion + inputText.cursorPosition = inputText.text.length + } + + onLogViewChanged: function(logText) { + logView.text = logText + logView.visible = true + bufferText.visible = false + logButton.checked = true + var vbar = bufferScroll.ScrollBar.vertical + vbar.position = 1.0 - vbar.size + } + } + + SoundEffect { + id: beepSound + source: "qrc:/beep.wav" + volume: 0.5 + } ColumnLayout { - id: column anchors.fill: parent anchors.margins: 6 + spacing: 6 - ScrollView { - id: bufferScroll + // Topic label + Label { + id: topicLabel + Layout.fillWidth: true + text: connection.topic + textFormat: Text.RichText + wrapMode: Text.Wrap + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + visible: text.length > 0 + padding: 4 + background: Rectangle { + color: palette.base + border.color: palette.mid + border.width: 1 + } + } + + // Split view between buffer list and buffer display area + SplitView { + id: mainSplit Layout.fillWidth: true Layout.fillHeight: true - TextArea { - id: buffer - text: qsTr("Buffer text") + orientation: Qt.Horizontal + + // Buffer list + Rectangle { + SplitView.preferredWidth: 200 + SplitView.minimumWidth: 150 + color: "transparent" + border.color: palette.mid + border.width: 1 + + ListView { + id: bufferList + anchors.fill: parent + anchors.margins: 1 + clip: true + + model: connection.bufferListModel + + Connections { + target: connection.bufferListModel + function onBufferActivated() { + bufferList.currentIndex = connection.bufferListModel.getCurrentBufferIndex() + } + } + + delegate: ItemDelegate { + width: bufferList.width + text: model.displayText + font.bold: model.isBold + highlighted: ListView.isCurrentItem + contentItem: Label { + text: parent.text + font: parent.font + color: model.highlightColor.valid ? + model.highlightColor : palette.text + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + onClicked: { + bufferList.currentIndex = index + connection.activateBuffer(model.bufferName) + } + } + + ScrollBar.vertical: ScrollBar {} + } + } + + // Buffer display area + ScrollView { + id: bufferScroll + SplitView.fillWidth: true + ScrollBar.vertical.policy: ScrollBar.AlwaysOn + + TextArea { + id: bufferText + textFormat: Text.RichText + readOnly: true + wrapMode: Text.Wrap + selectByMouse: true + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + Connections { + target: connection + function onCurrentBufferChanged() { + connection.populateBufferDocument(bufferText.textDocument) + bufferText.visible = true + logView.visible = false + logButton.checked = false + scrollToBottom() + + // Restore input for the new buffer + inputText.text = connection.getBufferInput() + inputText.cursorPosition = connection.getBufferInputStart() + inputText.select(connection.getBufferInputStart(), connection.getBufferInputEnd()) + } + } + } + + TextArea { + id: logView + visible: false + textFormat: Text.RichText + readOnly: true + wrapMode: Text.Wrap + selectByMouse: true + font.family: "monospace" + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + } } } + // Status area RowLayout { - id: row Layout.fillWidth: true + spacing: 6 Label { + id: promptLabel + text: connection.prompt + } + + Item { Layout.fillWidth: true - id: prompt - text: qsTr("Prompt") } - Label { + ToolButton { + id: boldButton + text: "B" + font.bold: true + checkable: true + ToolTip.text: "Bold" + ToolTip.visible: hovered + onClicked: { + inputText.font.bold = checked + } + } + + ToolButton { + id: italicButton + text: "I" + font.italic: true + checkable: true + ToolTip.text: "Italic" + ToolTip.visible: hovered + onClicked: { + inputText.font.italic = checked + } + } + + ToolButton { + id: underlineButton + text: "U" + font.underline: true + checkable: true + ToolTip.text: "Underline" + ToolTip.visible: hovered + onClicked: { + inputText.font.underline = checked + } + } + + Item { Layout.fillWidth: true - id: status + } + + Label { + id: statusLabel + text: connection.status horizontalAlignment: Text.AlignRight - text: qsTr("Status") + } + + ToolButton { + id: logButton + text: "Log" + checkable: true + ToolTip.text: "View buffer log" + ToolTip.visible: hovered + onClicked: { + if (checked) { + connection.toggleBufferLog() + } else { + logView.visible = false + bufferText.visible = true + logView.text = "" + } + } + } + + ToolButton { + text: "↓" + ToolTip.text: "Scroll to bottom" + ToolTip.visible: hovered + enabled: { + var vbar = bufferScroll.ScrollBar.vertical + return vbar.position + vbar.size < 1.0 + } + onClicked: scrollToBottom() } } - TextArea { - id: input + // Input area + ScrollView { Layout.fillWidth: true - text: qsTr("Input") + Layout.preferredHeight: Math.min(inputText.contentHeight + 12, 120) + + TextArea { + id: inputText + wrapMode: Text.Wrap + selectByMouse: true + textFormat: Text.RichText + + Keys.onReturnPressed: function(event) { + if (event.modifiers & Qt.ShiftModifier) { + // Allow Shift+Enter for newline + return + } + event.accepted = true + if (text.length > 0) { + connection.sendInput(text) + text = "" + } + } + + Keys.onUpPressed: { + var history = connection.getInputHistoryUp() + if (history.length > 0) { + text = history + } + } + + Keys.onDownPressed: { + var history = connection.getInputHistoryDown() + if (history.length > 0) { + text = history + } + } + + Keys.onTabPressed: { + connection.requestCompletion(text, cursorPosition) + } + } } } - Component.onCompleted: {} + // Keyboard shortcuts + Shortcut { + sequences: ["F5", "Alt+PgUp", "Ctrl+PgUp"] + onActivated: connection.activatePreviousBuffer() + } + + Shortcut { + sequences: ["F6", "Alt+PgDown", "Ctrl+PgDown"] + onActivated: connection.activateNextBuffer() + } + Shortcut { + sequences: ["Ctrl+Tab", "Alt+Tab"] + onActivated: connection.activateLastBuffer() + } + + Shortcut { + sequence: "Alt+A" + onActivated: connection.activateNextWithActivity() + } + + Shortcut { + sequence: "Alt+!" + onActivated: connection.activateNextHighlighted() + } + + Shortcut { + sequence: "Alt+H" + onActivated: connection.toggleUnimportant() + } + + Shortcut { + sequence: "PgUp" + onActivated: { + var vbar = bufferScroll.ScrollBar.vertical + vbar.position = Math.max(0, vbar.position - vbar.size) + } + } + + Shortcut { + sequence: "PgDown" + onActivated: { + var vbar = bufferScroll.ScrollBar.vertical + vbar.position = Math.min(1 - vbar.size, vbar.position + vbar.size) + } + } + + // Helper function to scroll to bottom + function scrollToBottom() { + var vbar = bufferScroll.ScrollBar.vertical + vbar.position = 1.0 - vbar.size + } + + // Connection dialog Dialog { - id: connect + id: connectDialog title: "Connect to relay" anchors.centerIn: parent modal: true - visible: true + closePolicy: Popup.NoAutoClose + + onOpened: connectHost.forceActiveFocus() - onRejected: Qt.quit() onAccepted: { - // TODO(p): Store the host, store the port, initiate connection. + connection.host = connectHost.text + connection.port = connectPort.text + connection.connectToRelay() } + onRejected: Qt.quit() + GridLayout { anchors.fill: parent - anchors.margins: 6 columns: 2 - // It is a bit silly that one has to do everything manually. - Keys.onReturnPressed: connect.accept() - Label { text: "Host:" } TextField { id: connectHost Layout.fillWidth: true - // And if this doesn't work reliably, do it after open(). + text: connection.host || "localhost" focus: true + selectByMouse: true + onAccepted: connectDialog.accept() } + Label { text: "Port:" } TextField { id: connectPort Layout.fillWidth: true + text: connection.port || "" + selectByMouse: true + validator: IntValidator { bottom: 0; top: 65535 } + onAccepted: connectDialog.accept() } } @@ -92,14 +424,27 @@ ApplicationWindow { Button { text: qsTr("Connect") DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole - Keys.onReturnPressed: connect.accept() highlighted: true } Button { - text: qsTr("Close") - DialogButtonBox.buttonRole: DialogButtonBox.DestructiveRole - Keys.onReturnPressed: connect.reject() + text: qsTr("Exit") + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole } } } + + // Error dialog + MessageDialog { + id: errorDialog + title: "Error" + buttons: MessageDialog.Ok + } + + Component.onCompleted: { + if (!connection.connected) { + connectDialog.open() + } else { + inputText.forceActiveFocus() + } + } } |
