/* * xT.cpp: Qt frontend for xC * * Copyright (c) 2024, Přemysl Eric Janouch * * 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 items; }; struct Buffer { QString buffer_name; bool hide_unimportant = {}; Relay::BufferKind kind = {}; QString server_name; std::vector lines; // Channel: std::vector 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 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 command_callbacks; std::vector buffers; ///< List of all buffers QString buffer_current; ///< Current buffer name or "" QString buffer_last; ///< Previous buffer name or "" std::map 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 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); auto len = qToBigEndian(w.data.size()); auto prefix = reinterpret_cast(&len); auto mdata = reinterpret_cast(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 &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 += ""; } // 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 &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 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(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(item)) { cf = QTextCharFormat(); inverse = false; } else if (dynamic_cast(item)) { if (cf.fontWeight() <= QFont::Normal) cf.setFontWeight(QFont::Bold); else cf.setFontWeight(QFont::Normal); } else if (dynamic_cast(item)) { cf.setFontItalic(!cf.fontItalic()); } else if (dynamic_cast(item)) { cf.setFontUnderline(!cf.fontUnderline()); } else if (dynamic_cast(item)) { cf.setFontStrikeOut(!cf.fontStrikeOut()); } else if (dynamic_cast(item)) { inverse = !inverse; } else if (dynamic_cast(item)) { cf.setFontFixedPitch(!cf.fontFixedPitch()); } else if (auto data = dynamic_cast(item)) { if (data->color < 0) { cf.clearForeground(); } else { cf.setForeground(convert_color(data->color)); } } else if (auto data = dynamic_cast(item)) { if (data->color < 0) { cf.clearBackground(); } else { cf.setBackground(convert_color(data->color)); } } } static std::vector convert_items(const std::vector> &items) { QTextCharFormat cf; std::vector result; bool inverse = false; for (const auto &it : items) { auto text = dynamic_cast(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 ¤t) { 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::const_iterator begin, std::vector::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(m.data.get()); relay_process_callbacks(data->command_seq, data->error, nullptr); break; } case Relay::Event::RESPONSE: { auto data = dynamic_cast(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(*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(*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( data.context.get())) b->server_name = QString::fromStdWString(context->server_name); if (auto context = dynamic_cast( 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( 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(*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(*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(*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(*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(*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(*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(*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( 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(*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(*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(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(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 rotated_buffers() { std::vector 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"