summaryrefslogtreecommitdiff
path: root/xT
diff options
context:
space:
mode:
Diffstat (limited to 'xT')
-rw-r--r--xT/CMakeLists.txt99
-rw-r--r--xT/config.h.in7
-rw-r--r--xT/xT-highlighted.svg29
-rw-r--r--xT/xT.cpp1723
-rw-r--r--xT/xT.desktop8
-rw-r--r--xT/xT.svg29
6 files changed, 1895 insertions, 0 deletions
diff --git a/xT/CMakeLists.txt b/xT/CMakeLists.txt
new file mode 100644
index 0000000..8157805
--- /dev/null
+++ b/xT/CMakeLists.txt
@@ -0,0 +1,99 @@
+# As per Qt 6.8 documentation, at least 3.16 is necessary
+cmake_minimum_required (VERSION 3.21.1)
+
+file (READ ../xK-version project_version)
+configure_file (../xK-version xK-version.tag COPYONLY)
+string (STRIP "${project_version}" project_version)
+
+# This is an entirely separate CMake project.
+project (xT VERSION "${project_version}"
+ DESCRIPTION "Qt frontend for xC" LANGUAGES CXX)
+
+set (CMAKE_CXX_STANDARD 17)
+set (CMAKE_CXX_STANDARD_REQUIRED ON)
+
+find_package (Qt6 REQUIRED COMPONENTS Widgets Network Multimedia)
+qt_standard_project_setup ()
+
+add_compile_options ("$<$<CXX_COMPILER_ID:MSVC>:/utf-8>")
+add_compile_options ("$<$<CXX_COMPILER_ID:GNU>:-Wall;-Wextra>")
+add_compile_options ("$<$<CXX_COMPILER_ID:Clang>:-Wall;-Wextra>")
+
+set (project_config "${PROJECT_BINARY_DIR}/config.h")
+configure_file ("${PROJECT_SOURCE_DIR}/config.h.in" "${project_config}")
+include_directories ("${PROJECT_SOURCE_DIR}" "${PROJECT_BINARY_DIR}")
+
+# Produce a beep sample
+find_program (sox_EXECUTABLE sox REQUIRED)
+set (beep "${PROJECT_BINARY_DIR}/beep.wav")
+add_custom_command (OUTPUT "${beep}"
+ COMMAND ${sox_EXECUTABLE} -b 16 -Dr 44100 -n "${beep}"
+ synth 0.1 0 25 triangle 800 vol 0.5 fade t 0 -0 0.005 pad 0 0.05
+ COMMENT "Generating a beep sample" VERBATIM)
+set_property (SOURCE "${beep}" APPEND PROPERTY QT_RESOURCE_ALIAS beep.wav)
+
+# Rasterize SVG icons
+set (root "${PROJECT_SOURCE_DIR}/..")
+set (CMAKE_MODULE_PATH "${root}/liberty/cmake")
+include (IconUtils)
+
+# It might generally be better to use QtSvg, though it is an extra dependency.
+# The icon_to_png macro is not intended to be used like this.
+foreach (icon xT xT-highlighted)
+ icon_to_png (${icon} "${PROJECT_SOURCE_DIR}/${icon}.svg"
+ 48 "${PROJECT_BINARY_DIR}/resources" icon_png)
+ set_property (SOURCE "${icon_png}"
+ APPEND PROPERTY QT_RESOURCE_ALIAS "${icon}.png")
+ list (APPEND icon_rsrc_list "${icon_png}")
+endforeach ()
+
+# The largest size is mainly for an appropriately sized Windows icon
+set (icon_base "${PROJECT_BINARY_DIR}/icons")
+set (icon_png_list)
+foreach (icon_size 16 32 48 256)
+ icon_to_png (xT "${PROJECT_SOURCE_DIR}/xT.svg"
+ ${icon_size} "${icon_base}" icon_png)
+ list (APPEND icon_png_list "${icon_png}")
+endforeach ()
+add_custom_target (icons ALL DEPENDS ${icon_png_list})
+if (WIN32)
+ list (REMOVE_ITEM icon_png_list "${icon_png}")
+ set (icon_ico "${PROJECT_BINARY_DIR}/xT.ico")
+ icon_for_win32 ("${icon_ico}" "${icon_png_list}" "${icon_png}")
+
+ set (resource_file "${PROJECT_BINARY_DIR}/xT.rc")
+ list (APPEND project_sources "${resource_file}")
+ add_custom_command (OUTPUT "${resource_file}"
+ COMMAND ${CMAKE_COMMAND} -E echo "1 ICON \"xT.ico\""
+ > ${resource_file} VERBATIM)
+ set_property (SOURCE "${resource_file}"
+ APPEND PROPERTY OBJECT_DEPENDS ${icon_ico})
+endif ()
+
+# Build the main executable and link it
+find_program (awk_EXECUTABLE awk ${find_program_REQUIRE})
+add_custom_command (OUTPUT xC-proto.cpp
+ COMMAND ${CMAKE_COMMAND} -E env LC_ALL=C ${awk_EXECUTABLE}
+ -f ${root}/liberty/tools/lxdrgen.awk
+ -f ${root}/liberty/tools/lxdrgen-cpp.awk
+ -v PrefixCamel=Relay
+ ${root}/xC.lxdr > xC-proto.cpp
+ DEPENDS
+ ${root}/liberty/tools/lxdrgen.awk
+ ${root}/liberty/tools/lxdrgen-cpp.awk
+ ${root}/xC.lxdr
+ COMMENT "Generating xC relay protocol code" VERBATIM)
+add_custom_target (xC-proto DEPENDS ${PROJECT_BINARY_DIR}/xC-proto.cpp)
+
+list (APPEND project_sources "${root}/liberty/tools/lxdrgen-cpp-qt.cpp")
+qt_add_executable (xT xT.cpp ${project_config} ${project_sources})
+add_dependencies (xT xC-proto)
+qt_add_resources (xT "rsrc" PREFIX / FILES "${beep}" ${icon_rsrc_list})
+target_link_libraries (xT PRIVATE Qt6::Widgets Qt6::Network Qt6::Multimedia)
+set_target_properties (xT PROPERTIES WIN32_EXECUTABLE ON MACOSX_BUNDLE ON)
+
+# At least with MinGW, this is a fully independent portable executable
+# TODO(p): Figure this out once it builds.
+install (TARGETS xT DESTINATION .)
+set (CPACK_GENERATOR ZIP)
+include (CPack)
diff --git a/xT/config.h.in b/xT/config.h.in
new file mode 100644
index 0000000..d31abdd
--- /dev/null
+++ b/xT/config.h.in
@@ -0,0 +1,7 @@
+#ifndef CONFIG_H
+#define CONFIG_H
+
+#define PROJECT_NAME "${PROJECT_NAME}"
+#define PROJECT_VERSION "${project_version}"
+
+#endif // ! CONFIG_H
diff --git a/xT/xT-highlighted.svg b/xT/xT-highlighted.svg
new file mode 100644
index 0000000..e624b4b
--- /dev/null
+++ b/xT/xT-highlighted.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.1" width="48" height="48" viewBox="0 0 48 48"
+ xmlns="http://www.w3.org/2000/svg">
+
+ <defs>
+ <radialGradient id="green-x">
+ <stop stop-color="hsl(66, 100%, 80%)" offset="0" />
+ <stop stop-color="hsl(66, 100%, 50%)" offset="1" />
+ </radialGradient>
+ <radialGradient id="orange">
+ <stop stop-color="hsl(36, 100%, 60%)" offset="0" />
+ <stop stop-color="hsl(23, 100%, 60%)" offset="1" />
+ </radialGradient>
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
+ <feDropShadow dx="0" dy="0" stdDeviation="0.05"
+ flood-color="rgba(0, 0, 0, .5)" />
+ </filter>
+ </defs>
+
+ <!-- XXX: librsvg screws up shadows on rotated objects. -->
+ <g filter="url(#shadow)" transform="translate(24 3) scale(16)">
+ <path fill="url(#orange)" stroke="hsl(36, 100%, 20%)" stroke-width="0.1"
+ d="M-.8 0 H.8 V.5 H.25 V2.625 H-.25 V.5 H-.8 Z" />
+ </g>
+ <g filter="url(#shadow)" transform="translate(24 28) rotate(-45) scale(16)">
+ <path fill="url(#green-x)" stroke="hsl(66, 100%, 20%)" stroke-width="0.1"
+ d="M-.25 -1 H.25 V-.25 H1 V.25 H.25 V1 H-.25 V.25 H-1 V-.25 H-.25 Z" />
+ </g>
+</svg>
diff --git a/xT/xT.cpp b/xT/xT.cpp
new file mode 100644
index 0000000..0f6de57
--- /dev/null
+++ b/xT/xT.cpp
@@ -0,0 +1,1723 @@
+/*
+ * 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
+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<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;
+
+ 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"
diff --git a/xT/xT.desktop b/xT/xT.desktop
new file mode 100644
index 0000000..eeae4fd
--- /dev/null
+++ b/xT/xT.desktop
@@ -0,0 +1,8 @@
+[Desktop Entry]
+Type=Application
+Name=xT
+GenericName=IRC Client
+Icon=xT
+Exec=xT
+StartupNotify=false
+Categories=Network;Chat;IRCClient;
diff --git a/xT/xT.svg b/xT/xT.svg
new file mode 100644
index 0000000..0dd85bc
--- /dev/null
+++ b/xT/xT.svg
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg version="1.1" width="48" height="48" viewBox="0 0 48 48"
+ xmlns="http://www.w3.org/2000/svg">
+
+ <defs>
+ <radialGradient id="grey-x">
+ <stop stop-color="hsl(66, 0%, 90%)" offset="0" />
+ <stop stop-color="hsl(66, 0%, 80%)" offset="1" />
+ </radialGradient>
+ <radialGradient id="orange">
+ <stop stop-color="hsl(36, 100%, 60%)" offset="0" />
+ <stop stop-color="hsl(23, 100%, 60%)" offset="1" />
+ </radialGradient>
+ <filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
+ <feDropShadow dx="0" dy="0" stdDeviation="0.05"
+ flood-color="rgba(0, 0, 0, .5)" />
+ </filter>
+ </defs>
+
+ <!-- XXX: librsvg screws up shadows on rotated objects. -->
+ <g filter="url(#shadow)" transform="translate(24 28) rotate(-45) scale(16)">
+ <path fill="url(#grey-x)" stroke="hsl(66, 0%, 30%)" stroke-width="0.1"
+ d="M-.25 -1 H.25 V-.25 H1 V.25 H.25 V1 H-.25 V.25 H-1 V-.25 H-.25 Z" />
+ </g>
+ <g filter="url(#shadow)" transform="translate(24 3) scale(16)">
+ <path fill="url(#orange)" stroke="hsl(36, 100%, 20%)" stroke-width="0.1"
+ d="M-.8 0 H.8 V.5 H.25 V2.625 H-.25 V.5 H-.8 Z" />
+ </g>
+</svg>