From 918c589c657be0c0885610d2eaafcd21a3ab1ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C5=99emysl=20Eric=20Janouch?= Date: Tue, 3 Dec 2024 15:25:21 +0100 Subject: [PATCH] Add a Qt Widgets frontend to xC This is very much a work in progress, though functional. Qt Widgets are basically non-working on Android, though Qt Quick requires a radically different approach. --- README.adoc | 6 +- liberty | 2 +- xT/CMakeLists.txt | 99 +++ xT/config.h.in | 7 + xT/xT-highlighted.svg | 29 + xT/xT.cpp | 1723 +++++++++++++++++++++++++++++++++++++++++ xT/xT.desktop | 8 + xT/xT.svg | 29 + 8 files changed, 1899 insertions(+), 4 deletions(-) create mode 100644 xT/CMakeLists.txt create mode 100644 xT/config.h.in create mode 100644 xT/xT-highlighted.svg create mode 100644 xT/xT.cpp create mode 100644 xT/xT.desktop create mode 100644 xT/xT.svg diff --git a/README.adoc b/README.adoc index eae2c82..0d507a2 100644 --- a/README.adoc +++ b/README.adoc @@ -33,9 +33,9 @@ including link:xC.adoc#_key_bindings[keyboard shortcuts]. image::xP.webp[align="center"] -xA, xW, xM ----------- -The native frontends for 'xC'. Using them is not recommended. +xA, xT, xW, xM +-------------- +Other frontends for 'xC'. Using them is not recommended. xD -- diff --git a/liberty b/liberty index 492815c..149938c 160000 --- a/liberty +++ b/liberty @@ -1 +1 @@ -Subproject commit 492815c8fc38ad6e333b2f1c5094a329e3076155 +Subproject commit 149938cc445f99b1aa40c490014aad72cf698dec 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 ("$<$:/utf-8>") +add_compile_options ("$<$:-Wall;-Wextra>") +add_compile_options ("$<$:-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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 + * + * 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; + + 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" 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + +