1725 lines
46 KiB
C++
1725 lines
46 KiB
C++
/*
|
|
* xT.cpp: Qt frontend for xC
|
|
*
|
|
* Copyright (c) 2024, Přemysl Eric Janouch <p@janouch.name>
|
|
*
|
|
* Permission to use, copy, modify, and/or distribute this software for any
|
|
* purpose with or without fee is hereby granted.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
|
* SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
|
|
* OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
|
|
* CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
*
|
|
*/
|
|
|
|
#include "xC-proto.cpp"
|
|
#include "config.h"
|
|
|
|
#include <cstdint>
|
|
#include <functional>
|
|
#include <map>
|
|
#include <string>
|
|
|
|
#include <QtEndian>
|
|
#include <QtDebug>
|
|
|
|
#include <QDateTime>
|
|
#include <QRegularExpression>
|
|
|
|
#include <QApplication>
|
|
#include <QFontDatabase>
|
|
#include <QFormLayout>
|
|
#include <QHBoxLayout>
|
|
#include <QKeyEvent>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QListWidget>
|
|
#include <QMainWindow>
|
|
#include <QMessageBox>
|
|
#include <QPushButton>
|
|
#include <QScrollBar>
|
|
#include <QShortcut>
|
|
#include <QSplitter>
|
|
#include <QStackedWidget>
|
|
#include <QTextBrowser>
|
|
#include <QTextBlock>
|
|
#include <QTextEdit>
|
|
#include <QTimer>
|
|
#include <QToolButton>
|
|
#include <QVBoxLayout>
|
|
#include <QWindow>
|
|
|
|
#include <QSoundEffect>
|
|
#include <QTcpSocket>
|
|
|
|
struct Server {
|
|
Relay::ServerState state = {};
|
|
QString user;
|
|
QString user_modes;
|
|
};
|
|
|
|
struct BufferLineItem {
|
|
QTextCharFormat format = {};
|
|
QString text;
|
|
};
|
|
|
|
struct BufferLine {
|
|
/// Leaked from another buffer, but temporarily staying in another one.
|
|
bool leaked = {};
|
|
|
|
bool is_unimportant = {};
|
|
bool is_highlight = {};
|
|
Relay::Rendition rendition = {};
|
|
uint64_t when = {};
|
|
std::vector<BufferLineItem> items;
|
|
};
|
|
|
|
struct Buffer {
|
|
QString buffer_name;
|
|
bool hide_unimportant = {};
|
|
Relay::BufferKind kind = {};
|
|
QString server_name;
|
|
std::vector<BufferLine> lines;
|
|
|
|
// Channel:
|
|
|
|
std::vector<BufferLineItem> topic;
|
|
QString modes;
|
|
|
|
// Stats:
|
|
|
|
uint32_t new_messages = {};
|
|
uint32_t new_unimportant_messages = {};
|
|
bool highlighted = {};
|
|
|
|
// Input:
|
|
|
|
// The input is stored as rich text.
|
|
QString input;
|
|
int input_start = {};
|
|
int input_end = {};
|
|
std::vector<QString> history;
|
|
size_t history_at = {};
|
|
};
|
|
|
|
using Callback = std::function<
|
|
void(std::wstring error, const Relay::ResponseData *response)>;
|
|
|
|
struct {
|
|
QMainWindow *wMain; ///< Main program window
|
|
QLabel *wTopic; ///< Channel topic
|
|
QListWidget *wBufferList; ///< Buffer list
|
|
QStackedWidget *wStack; ///< Buffer backlog/log stack
|
|
QTextBrowser *wBuffer; ///< Buffer backlog
|
|
QTextBrowser *wLog; ///< Buffer log
|
|
QLabel *wPrompt; ///< User name, etc.
|
|
QToolButton *wButtonB; ///< Toggle bold formatting
|
|
QToolButton *wButtonI; ///< Toggle italic formatting
|
|
QToolButton *wButtonU; ///< Toggle underlined formatting
|
|
QLabel *wStatus; ///< Buffer name, etc.
|
|
QToolButton *wButtonLog; ///< Buffer log toggle button
|
|
QToolButton *wButtonDown; ///< Scroll indicator
|
|
QTextEdit *wInput; ///< User input
|
|
|
|
QTimer *date_change_timer; ///< Timer for day changes
|
|
|
|
QLineEdit *wConnectHost; ///< Connection dialog: host
|
|
QLineEdit *wConnectPort; ///< Connection dialog: port
|
|
QDialog *wConnectDialog; ///< Connection details dialog
|
|
|
|
// Networking:
|
|
|
|
QString host; ///< Host as given by user
|
|
QString port; ///< Post/service as given by user
|
|
|
|
QTcpSocket *socket; ///< Buffered relay socket
|
|
|
|
// Relay protocol:
|
|
|
|
uint32_t command_seq; ///< Outgoing message counter
|
|
|
|
std::map<uint32_t, Callback> command_callbacks;
|
|
|
|
std::vector<Buffer> buffers; ///< List of all buffers
|
|
QString buffer_current; ///< Current buffer name or ""
|
|
QString buffer_last; ///< Previous buffer name or ""
|
|
|
|
std::map<QString, Server> servers;
|
|
} g;
|
|
|
|
static void
|
|
show_error_message(const QString &message)
|
|
{
|
|
QMessageBox::critical(g.wMain, "Error", message, QMessageBox::Ok);
|
|
}
|
|
|
|
static void
|
|
beep()
|
|
{
|
|
// We don't want to reuse the same instance.
|
|
auto *se = new QSoundEffect(g.wMain);
|
|
QObject::connect(se, &QSoundEffect::playingChanged, [=] {
|
|
if (!se->isPlaying())
|
|
se->deleteLater();
|
|
});
|
|
QObject::connect(se, &QSoundEffect::statusChanged, [=] {
|
|
if (se->status() == QSoundEffect::Error)
|
|
se->deleteLater();
|
|
});
|
|
|
|
se->setSource(QUrl("qrc:/beep.wav"));
|
|
se->setLoopCount(1);
|
|
se->setVolume(0.5);
|
|
se->play();
|
|
}
|
|
|
|
// --- Networking --------------------------------------------------------------
|
|
|
|
static void
|
|
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 ¤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<BufferLine>::const_iterator begin,
|
|
std::vector<BufferLine>::const_iterator line)
|
|
{
|
|
auto current = QDateTime::fromMSecsSinceEpoch(line->when);
|
|
auto last = line == begin ? QDateTime::currentDateTime()
|
|
: QDateTime::fromMSecsSinceEpoch((line - 1)->when);
|
|
|
|
bool sameline = buffer_reset_selection();
|
|
buffer_print_date_change(sameline, last, current);
|
|
|
|
auto timestamp = current.toString(&"\nHH:mm:ss"[sameline]);
|
|
sameline = false;
|
|
|
|
QTextCharFormat cf;
|
|
cf.setForeground(QColor(0xbb, 0xbb, 0xbb));
|
|
cf.setBackground(QColor(0xf8, 0xf8, 0xf8));
|
|
textedit_replacesel(g.wBuffer, cf, timestamp);
|
|
cf = QTextCharFormat();
|
|
textedit_replacesel(g.wBuffer, cf, " ");
|
|
|
|
// Tabstops won't quite help us here, since we need it centred.
|
|
QString prefix;
|
|
QTextCharFormat pcf;
|
|
pcf.setFontFixedPitch(true);
|
|
pcf.setFontWeight(QFont::Bold);
|
|
switch (line->rendition) {
|
|
break; case Relay::Rendition::BARE:
|
|
break; case Relay::Rendition::INDENT:
|
|
prefix = " ";
|
|
break; case Relay::Rendition::STATUS:
|
|
prefix = " - ";
|
|
break; case Relay::Rendition::ERROR:
|
|
prefix = "=!= ";
|
|
pcf.setForeground(QColor(0xff, 0, 0));
|
|
break; case Relay::Rendition::JOIN:
|
|
prefix = "——> ";
|
|
pcf.setForeground(QColor(0, 0x88, 0));
|
|
break; case Relay::Rendition::PART:
|
|
prefix = "<—— ";
|
|
pcf.setForeground(QColor(0x88, 0, 0));
|
|
break; case Relay::Rendition::ACTION:
|
|
prefix = " * ";
|
|
pcf.setForeground(QColor(0x88, 0, 0));
|
|
}
|
|
|
|
if (line->leaked) {
|
|
auto color = g.wBuffer->palette().color(
|
|
QPalette::Disabled, QPalette::Text);
|
|
pcf.setForeground(color);
|
|
if (!prefix.isEmpty()) {
|
|
textedit_replacesel(g.wBuffer, pcf, prefix);
|
|
}
|
|
|
|
for (auto it : line->items) {
|
|
it.format.setForeground(color);
|
|
it.format.clearBackground();
|
|
textedit_replacesel(g.wBuffer, it.format, it.text);
|
|
}
|
|
} else {
|
|
if (!prefix.isEmpty())
|
|
textedit_replacesel(g.wBuffer, pcf, prefix);
|
|
for (const auto &it : line->items)
|
|
textedit_replacesel(g.wBuffer, it.format, it.text);
|
|
}
|
|
}
|
|
|
|
static void
|
|
buffer_print_separator()
|
|
{
|
|
buffer_reset_selection();
|
|
|
|
QTextFrameFormat ff;
|
|
ff.setBackground(QColor(0xff, 0x5f, 0x00));
|
|
ff.setHeight(1);
|
|
// FIXME: When the current frame was empty, this seems to add a newline.
|
|
g.wBuffer->textCursor().insertFrame(ff);
|
|
}
|
|
|
|
static void
|
|
refresh_buffer(const Buffer &b)
|
|
{
|
|
g.wBuffer->clear();
|
|
|
|
size_t i = 0, mark_before = b.lines.size() -
|
|
b.new_messages - b.new_unimportant_messages;
|
|
for (auto line = b.lines.begin(); line != b.lines.end(); ++line) {
|
|
if (i == mark_before)
|
|
buffer_print_separator();
|
|
if (!line->is_unimportant || !b.hide_unimportant)
|
|
buffer_print_line(b.lines.begin(), line);
|
|
|
|
i++;
|
|
}
|
|
|
|
buffer_print_and_watch_trailing_date_changes();
|
|
buffer_scroll_to_bottom();
|
|
// TODO(p): recheck_highlighted() here, or do we handle enough signals?
|
|
}
|
|
|
|
// --- Event processing --------------------------------------------------------
|
|
|
|
static void
|
|
relay_process_buffer_line(Buffer &b, Relay::EventData_BufferLine &m)
|
|
{
|
|
// Initial sync: skip all other processing, let highlights be.
|
|
auto bc = buffer_by_name(g.buffer_current);
|
|
if (!bc) {
|
|
b.lines.push_back(convert_buffer_line(m));
|
|
return;
|
|
}
|
|
|
|
// Retained mode is complicated.
|
|
bool display = (!m.is_unimportant || !bc->hide_unimportant) &&
|
|
(b.buffer_name == g.buffer_current || m.leak_to_active);
|
|
bool to_bottom = display && buffer_at_bottom();
|
|
bool visible = display &&
|
|
to_bottom &&
|
|
!g.wMain->isMinimized() &&
|
|
!g.wLog->isVisible();
|
|
bool separate = display &&
|
|
!visible && !bc->new_messages && !bc->new_unimportant_messages;
|
|
|
|
auto line = b.lines.insert(b.lines.end(), convert_buffer_line(m));
|
|
if (!(visible || m.leak_to_active) ||
|
|
b.new_messages || b.new_unimportant_messages) {
|
|
if (line->is_unimportant || m.leak_to_active)
|
|
b.new_unimportant_messages++;
|
|
else
|
|
b.new_messages++;
|
|
}
|
|
|
|
if (m.leak_to_active) {
|
|
auto line = bc->lines.insert(bc->lines.end(), convert_buffer_line(m));
|
|
line->leaked = true;
|
|
if (!visible || bc->new_messages || bc->new_unimportant_messages) {
|
|
if (line->is_unimportant)
|
|
bc->new_unimportant_messages++;
|
|
else
|
|
bc->new_messages++;
|
|
}
|
|
}
|
|
if (separate)
|
|
buffer_print_separator();
|
|
if (display)
|
|
buffer_print_line(bc->lines.begin(), bc->lines.end() - 1);
|
|
if (to_bottom)
|
|
buffer_scroll_to_bottom();
|
|
|
|
if (line->is_highlight || (!visible && !line->is_unimportant &&
|
|
b.kind == Relay::BufferKind::PRIVATE_MESSAGE)) {
|
|
beep();
|
|
|
|
if (!visible) {
|
|
b.highlighted = true;
|
|
refresh_icon();
|
|
}
|
|
}
|
|
|
|
refresh_buffer_list();
|
|
}
|
|
|
|
static void
|
|
relay_process_callbacks(uint32_t command_seq,
|
|
const std::wstring& error, const Relay::ResponseData *response)
|
|
{
|
|
auto &callbacks = g.command_callbacks;
|
|
auto handler = callbacks.find(command_seq);
|
|
if (handler == callbacks.end()) {
|
|
// TODO(p): Warn about an unawaited response.
|
|
} else {
|
|
if (handler->second)
|
|
handler->second(error, response);
|
|
callbacks.erase(handler);
|
|
}
|
|
|
|
// We don't particularly care about wraparound issues.
|
|
while (!callbacks.empty() && callbacks.begin()->first <= command_seq) {
|
|
auto front = callbacks.begin();
|
|
if (front->second)
|
|
front->second(L"No response", nullptr);
|
|
callbacks.erase(front);
|
|
}
|
|
}
|
|
|
|
static void
|
|
relay_process_message(const Relay::EventMessage &m)
|
|
{
|
|
switch (m.data->event) {
|
|
case Relay::Event::ERROR:
|
|
{
|
|
auto data = dynamic_cast<Relay::EventData_Error *>(m.data.get());
|
|
relay_process_callbacks(data->command_seq, data->error, nullptr);
|
|
break;
|
|
}
|
|
case Relay::Event::RESPONSE:
|
|
{
|
|
auto data = dynamic_cast<Relay::EventData_Response *>(m.data.get());
|
|
relay_process_callbacks(data->command_seq, {}, data->data.get());
|
|
break;
|
|
}
|
|
|
|
case Relay::Event::PING:
|
|
{
|
|
auto pong = new Relay::CommandData_PingResponse();
|
|
pong->event_seq = m.event_seq;
|
|
relay_send(pong);
|
|
break;
|
|
}
|
|
|
|
case Relay::Event::BUFFER_LINE:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferLine &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
relay_process_buffer_line(*b, data);
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_UPDATE:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferUpdate &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b) {
|
|
b = &*g.buffers.insert(g.buffers.end(), Buffer());
|
|
b->buffer_name = QString::fromStdWString(data.buffer_name);
|
|
|
|
auto item = new QListWidgetItem;
|
|
refresh_buffer_list_item(item, *b);
|
|
g.wBufferList->addItem(item);
|
|
}
|
|
|
|
bool hiding_toggled = b->hide_unimportant != data.hide_unimportant;
|
|
b->hide_unimportant = data.hide_unimportant;
|
|
b->kind = data.context->kind;
|
|
b->server_name.clear();
|
|
if (auto context = dynamic_cast<Relay::BufferContext_Server *>(
|
|
data.context.get()))
|
|
b->server_name = QString::fromStdWString(context->server_name);
|
|
if (auto context = dynamic_cast<Relay::BufferContext_Channel *>(
|
|
data.context.get())) {
|
|
b->server_name = QString::fromStdWString(context->server_name);
|
|
b->modes = QString::fromStdWString(context->modes);
|
|
b->topic = convert_items(context->topic);
|
|
}
|
|
if (auto context = dynamic_cast<Relay::BufferContext_PrivateMessage *>(
|
|
data.context.get()))
|
|
b->server_name = QString::fromStdWString(context->server_name);
|
|
|
|
if (b->buffer_name == g.buffer_current) {
|
|
refresh_topic(b->topic);
|
|
refresh_status();
|
|
|
|
if (hiding_toggled)
|
|
refresh_buffer(*b);
|
|
}
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_STATS:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferStats &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
b->new_messages = data.new_messages;
|
|
b->new_unimportant_messages = data.new_unimportant_messages;
|
|
b->highlighted = data.highlighted;
|
|
|
|
refresh_icon();
|
|
refresh_buffer_list();
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_RENAME:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferRename &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
auto original = b->buffer_name;
|
|
b->buffer_name = QString::fromStdWString(data.new_);
|
|
|
|
if (original == g.buffer_current) {
|
|
g.buffer_current = b->buffer_name;
|
|
refresh_status();
|
|
}
|
|
refresh_buffer_list();
|
|
if (original == g.buffer_last)
|
|
g.buffer_last = b->buffer_name;
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_REMOVE:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferRemove &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
int index = b - g.buffers.data();
|
|
delete g.wBufferList->takeItem(index);
|
|
g.buffers.erase(g.buffers.begin() + index);
|
|
|
|
refresh_icon();
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_ACTIVATE:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferActivate &>(*m.data);
|
|
Buffer *old = buffer_by_name(g.buffer_current);
|
|
g.buffer_last = g.buffer_current;
|
|
g.buffer_current = QString::fromStdWString(data.buffer_name);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
if (old) {
|
|
old->new_messages = 0;
|
|
old->new_unimportant_messages = 0;
|
|
old->highlighted = false;
|
|
|
|
old->input = g.wInput->toHtml();
|
|
old->input_start = g.wInput->textCursor().selectionStart();
|
|
old->input_end = g.wInput->textCursor().selectionEnd();
|
|
|
|
// Note that we effectively overwrite the newest line
|
|
// with the current textarea contents, and jump there.
|
|
old->history_at = old->history.size();
|
|
}
|
|
|
|
if (g.wLog->isVisible())
|
|
buffer_toggle_log();
|
|
if (!g.wMain->isMinimized())
|
|
b->highlighted = false;
|
|
auto item = g.wBufferList->item(b - g.buffers.data());
|
|
refresh_buffer_list_item(item, *b);
|
|
g.wBufferList->setCurrentItem(item);
|
|
|
|
refresh_icon();
|
|
refresh_topic(b->topic);
|
|
refresh_buffer(*b);
|
|
refresh_prompt();
|
|
refresh_status();
|
|
|
|
g.wInput->setHtml(b->input);
|
|
g.wInput->textCursor().setPosition(b->input_start);
|
|
g.wInput->textCursor().setPosition(
|
|
b->input_end, QTextCursor::KeepAnchor);
|
|
g.wInput->setFocus();
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_INPUT:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferInput &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
if (b->history_at == b->history.size())
|
|
b->history_at++;
|
|
b->history.push_back(
|
|
irc_to_rich_text(QString::fromStdWString(data.text)));
|
|
break;
|
|
}
|
|
case Relay::Event::BUFFER_CLEAR:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_BufferClear &>(*m.data);
|
|
auto b = buffer_by_name(data.buffer_name);
|
|
if (!b)
|
|
break;
|
|
|
|
b->lines.clear();
|
|
if (b->buffer_name == g.buffer_current)
|
|
refresh_buffer(*b);
|
|
break;
|
|
}
|
|
|
|
case Relay::Event::SERVER_UPDATE:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_ServerUpdate &>(*m.data);
|
|
auto name = QString::fromStdWString(data.server_name);
|
|
if (!g.servers.count(name))
|
|
g.servers.emplace(name, Server());
|
|
|
|
auto &server = g.servers.at(name);
|
|
server.state = data.data->state;
|
|
|
|
server.user.clear();
|
|
server.user_modes.clear();
|
|
if (auto registered = dynamic_cast<Relay::ServerData_Registered *>(
|
|
data.data.get())) {
|
|
server.user = QString::fromStdWString(registered->user);
|
|
server.user_modes = QString::fromStdWString(registered->user_modes);
|
|
}
|
|
|
|
refresh_prompt();
|
|
break;
|
|
}
|
|
case Relay::Event::SERVER_RENAME:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_ServerRename &>(*m.data);
|
|
auto original = QString::fromStdWString(data.server_name);
|
|
g.servers.insert_or_assign(
|
|
QString::fromStdWString(data.new_), g.servers.at(original));
|
|
g.servers.erase(original);
|
|
break;
|
|
}
|
|
case Relay::Event::SERVER_REMOVE:
|
|
{
|
|
auto &data = dynamic_cast<Relay::EventData_ServerRemove &>(*m.data);
|
|
auto name = QString::fromStdWString(data.server_name);
|
|
g.servers.erase(name);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Networking --------------------------------------------------------------
|
|
|
|
static void
|
|
relay_show_dialog()
|
|
{
|
|
g.wConnectHost->setText(g.host);
|
|
g.wConnectPort->setText(g.port);
|
|
g.wConnectDialog->move(
|
|
g.wMain->frameGeometry().center() - g.wConnectDialog->rect().center());
|
|
switch (g.wConnectDialog->exec()) {
|
|
case QDialog::Accepted:
|
|
g.host = g.wConnectHost->text();
|
|
g.port = g.wConnectPort->text();
|
|
g.socket->connectToHost(g.host, g.port.toUShort());
|
|
break;
|
|
case QDialog::Rejected:
|
|
QCoreApplication::exit();
|
|
}
|
|
}
|
|
|
|
static void
|
|
relay_process_error([[maybe_unused]] QAbstractSocket::SocketError err)
|
|
{
|
|
show_error_message(g.socket->errorString());
|
|
g.socket->abort();
|
|
QTimer::singleShot(0, relay_show_dialog);
|
|
}
|
|
|
|
static void
|
|
relay_process_connected()
|
|
{
|
|
g.command_seq = 0;
|
|
g.command_callbacks.clear();
|
|
|
|
g.buffers.clear();
|
|
g.buffer_current.clear();
|
|
g.buffer_last.clear();
|
|
g.servers.clear();
|
|
|
|
refresh_icon();
|
|
refresh_topic({});
|
|
g.wBufferList->clear();
|
|
g.wBuffer->clear();
|
|
refresh_prompt();
|
|
refresh_status();
|
|
|
|
auto hello = new Relay::CommandData_Hello();
|
|
hello->version = Relay::VERSION;
|
|
relay_send(hello);
|
|
}
|
|
|
|
static bool
|
|
relay_process_buffer(QString &error)
|
|
{
|
|
// How I wish I could access the internal read buffer directly.
|
|
auto s = g.socket;
|
|
union {
|
|
uint32_t frame_len = 0;
|
|
char buf[sizeof frame_len];
|
|
};
|
|
while (s->peek(buf, sizeof buf) == sizeof buf) {
|
|
frame_len = qFromBigEndian(frame_len);
|
|
if (s->bytesAvailable() < qint64(sizeof frame_len + frame_len))
|
|
break;
|
|
|
|
s->skip(sizeof frame_len);
|
|
auto b = s->read(frame_len);
|
|
LibertyXDR::Reader r;
|
|
r.data = reinterpret_cast<const uint8_t *>(b.data());
|
|
r.length = b.size();
|
|
|
|
Relay::EventMessage m = {};
|
|
if (!m.deserialize(r) || r.length) {
|
|
error = "Deserialization failed.";
|
|
return false;
|
|
}
|
|
|
|
relay_process_message(m);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static void
|
|
relay_process_ready()
|
|
{
|
|
QString err;
|
|
if (!relay_process_buffer(err)) {
|
|
show_error_message(err);
|
|
g.socket->abort();
|
|
QTimer::singleShot(0, relay_show_dialog);
|
|
}
|
|
}
|
|
|
|
// --- Input line --------------------------------------------------------------
|
|
|
|
static void
|
|
input_set_contents(const QString &input)
|
|
{
|
|
g.wInput->setHtml(input);
|
|
|
|
auto cursor = g.wInput->textCursor();
|
|
cursor.movePosition(QTextCursor::End);
|
|
g.wInput->setTextCursor(cursor);
|
|
g.wInput->ensureCursorVisible();
|
|
}
|
|
|
|
static bool
|
|
input_submit()
|
|
{
|
|
auto b = buffer_by_name(g.buffer_current);
|
|
if (!b)
|
|
return false;
|
|
|
|
auto input = new Relay::CommandData_BufferInput();
|
|
input->buffer_name = b->buffer_name.toStdWString();
|
|
input->text = rich_text_to_irc(g.wInput).toStdWString();
|
|
|
|
// Buffer::history[Buffer::history.size()] is virtual,
|
|
// and is represented either by edit contents when it's currently
|
|
// being edited, or by Buffer::input in all other cases.
|
|
b->history.push_back(g.wInput->toHtml());
|
|
b->history_at = b->history.size();
|
|
input_set_contents("");
|
|
|
|
relay_send(input);
|
|
return true;
|
|
}
|
|
|
|
struct InputStamp {
|
|
int start = {};
|
|
int end = {};
|
|
QString input;
|
|
};
|
|
|
|
static InputStamp
|
|
input_stamp()
|
|
{
|
|
// Hopefully, the selection markers match the plain text characters.
|
|
auto start = g.wInput->textCursor().selectionStart();
|
|
auto end = g.wInput->textCursor().selectionEnd();
|
|
return {start, end, g.wInput->toPlainText()};
|
|
}
|
|
|
|
static void
|
|
input_complete(const InputStamp &state, const std::wstring &error,
|
|
const Relay::ResponseData_BufferComplete *response)
|
|
{
|
|
if (!response) {
|
|
show_error_message(QString::fromStdWString(error));
|
|
return;
|
|
}
|
|
|
|
auto utf8 = state.input.sliced(0, state.start).toUtf8();
|
|
auto preceding = QString(utf8.sliced(0, response->start));
|
|
if (response->completions.size() > 0) {
|
|
auto insert = response->completions.at(0);
|
|
if (response->completions.size() == 1)
|
|
insert += L" ";
|
|
|
|
auto cursor = g.wInput->textCursor();
|
|
cursor.setPosition(preceding.length());
|
|
cursor.setPosition(state.end, QTextCursor::KeepAnchor);
|
|
cursor.insertHtml(irc_to_rich_text(QString::fromStdWString(insert)));
|
|
}
|
|
|
|
if (response->completions.size() != 1)
|
|
beep();
|
|
|
|
// TODO(p): Show all completion options.
|
|
}
|
|
|
|
static bool
|
|
input_complete()
|
|
{
|
|
// TODO(p): Also add an increasing counter to the stamp.
|
|
auto state = input_stamp();
|
|
if (state.start != state.end)
|
|
return false;
|
|
|
|
auto utf8 = state.input.sliced(0, state.start).toUtf8();
|
|
auto complete = new Relay::CommandData_BufferComplete();
|
|
complete->buffer_name = g.buffer_current.toStdWString();
|
|
complete->text = state.input.toStdWString();
|
|
complete->position = utf8.size();
|
|
relay_send(complete, [state](auto error, auto response) {
|
|
auto stamp = input_stamp();
|
|
if (std::make_tuple(stamp.start, stamp.end, stamp.input) !=
|
|
std::make_tuple(state.start, state.end, state.input))
|
|
return;
|
|
input_complete(stamp, error,
|
|
dynamic_cast<const Relay::ResponseData_BufferComplete *>(response));
|
|
});
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
input_up()
|
|
{
|
|
auto b = buffer_by_name(g.buffer_current);
|
|
if (!b || b->history_at < 1)
|
|
return false;
|
|
|
|
if (b->history_at == b->history.size())
|
|
b->input = g.wInput->toHtml();
|
|
input_set_contents(b->history.at(--b->history_at));
|
|
return true;
|
|
}
|
|
|
|
static bool
|
|
input_down()
|
|
{
|
|
auto b = buffer_by_name(g.buffer_current);
|
|
if (!b || b->history_at >= b->history.size())
|
|
return false;
|
|
|
|
input_set_contents(++b->history_at == b->history.size()
|
|
? b->input
|
|
: b->history.at(b->history_at));
|
|
return true;
|
|
}
|
|
|
|
class InputEdit : public QTextEdit {
|
|
Q_OBJECT
|
|
|
|
public:
|
|
explicit InputEdit(QWidget *parent = nullptr) : QTextEdit(parent) {}
|
|
|
|
void keyPressEvent(QKeyEvent *event) override
|
|
{
|
|
auto scrollable = g.wLog->isVisible()
|
|
? g.wLog->verticalScrollBar()
|
|
: g.wBuffer->verticalScrollBar();
|
|
|
|
QKeyCombination combo(
|
|
event->modifiers() & ~Qt::KeypadModifier, Qt::Key(event->key()));
|
|
switch (combo.toCombined()) {
|
|
case Qt::Key_Return:
|
|
case Qt::Key_Enter:
|
|
input_submit();
|
|
break;
|
|
case QKeyCombination(Qt::ShiftModifier, Qt::Key_Return).toCombined():
|
|
case QKeyCombination(Qt::ShiftModifier, Qt::Key_Enter).toCombined():
|
|
// Qt amazingly inserts U+2028 LINE SEPARATOR instead.
|
|
this->textCursor().insertText("\n");
|
|
break;
|
|
case Qt::Key_Tab:
|
|
input_complete();
|
|
break;
|
|
case QKeyCombination(Qt::AltModifier, Qt::Key_P).toCombined():
|
|
case Qt::Key_Up:
|
|
input_up();
|
|
break;
|
|
case QKeyCombination(Qt::AltModifier, Qt::Key_N).toCombined():
|
|
case Qt::Key_Down:
|
|
input_down();
|
|
break;
|
|
case Qt::Key_PageUp:
|
|
scrollable->setValue(scrollable->value() - scrollable->pageStep());
|
|
break;
|
|
case Qt::Key_PageDown:
|
|
scrollable->setValue(scrollable->value() + scrollable->pageStep());
|
|
break;
|
|
|
|
default:
|
|
QTextEdit::keyPressEvent(event);
|
|
return;
|
|
}
|
|
event->accept();
|
|
}
|
|
};
|
|
|
|
// --- General UI --------------------------------------------------------------
|
|
|
|
class BufferEdit : public QTextBrowser {
|
|
Q_OBJECT
|
|
|
|
public:
|
|
explicit BufferEdit(QWidget *parent = nullptr) : QTextBrowser(parent) {}
|
|
|
|
void resizeEvent(QResizeEvent *event) override
|
|
{
|
|
bool to_bottom = buffer_at_bottom();
|
|
QTextBrowser::resizeEvent(event);
|
|
if (to_bottom) {
|
|
buffer_scroll_to_bottom();
|
|
} else {
|
|
recheck_highlighted();
|
|
refresh_status();
|
|
}
|
|
}
|
|
};
|
|
|
|
static void
|
|
build_main_window()
|
|
{
|
|
g.wMain = new QMainWindow;
|
|
refresh_icon();
|
|
|
|
auto central = new QWidget(g.wMain);
|
|
auto vbox = new QVBoxLayout(central);
|
|
vbox->setContentsMargins(4, 4, 4, 4);
|
|
|
|
g.wTopic = new QLabel(central);
|
|
g.wTopic->setTextFormat(Qt::RichText);
|
|
vbox->addWidget(g.wTopic);
|
|
|
|
auto splitter = new QSplitter(Qt::Horizontal, central);
|
|
splitter->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
|
|
|
g.wBufferList = new QListWidget(splitter);
|
|
g.wBufferList->setSizePolicy(
|
|
QSizePolicy::Preferred, QSizePolicy::Expanding);
|
|
QObject::connect(g.wBufferList, &QListWidget::currentRowChanged,
|
|
[](int row) {
|
|
if (row >= 0 && (size_t) row < g.buffers.size())
|
|
buffer_activate(g.buffers.at(row).buffer_name);
|
|
});
|
|
|
|
g.wStack = new QStackedWidget(splitter);
|
|
|
|
g.wBuffer = new BufferEdit(g.wStack);
|
|
g.wBuffer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
|
g.wBuffer->setReadOnly(true);
|
|
g.wBuffer->setTextInteractionFlags(
|
|
Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
|
|
Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
|
|
g.wBuffer->setOpenExternalLinks(true);
|
|
QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::valueChanged,
|
|
[]([[maybe_unused]] int value) {
|
|
recheck_highlighted();
|
|
refresh_status();
|
|
});
|
|
QObject::connect(g.wBuffer->verticalScrollBar(), &QScrollBar::rangeChanged,
|
|
[]([[maybe_unused]] int min, [[maybe_unused]] int max) {
|
|
recheck_highlighted();
|
|
refresh_status();
|
|
});
|
|
|
|
g.wLog = new QTextBrowser(g.wStack);
|
|
g.wLog->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
|
g.wLog->setReadOnly(true);
|
|
g.wLog->setTextInteractionFlags(
|
|
Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard |
|
|
Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
|
|
g.wLog->setOpenExternalLinks(true);
|
|
|
|
g.wStack->addWidget(g.wBuffer);
|
|
g.wStack->addWidget(g.wLog);
|
|
|
|
splitter->addWidget(g.wBufferList);
|
|
splitter->setStretchFactor(0, 1);
|
|
splitter->addWidget(g.wStack);
|
|
splitter->setStretchFactor(1, 2);
|
|
vbox->addWidget(splitter);
|
|
|
|
auto hbox = new QHBoxLayout();
|
|
g.wPrompt = new QLabel(central);
|
|
hbox->addWidget(g.wPrompt);
|
|
|
|
g.wButtonB = new QToolButton(central);
|
|
g.wButtonB->setText("&B");
|
|
g.wButtonB->setCheckable(true);
|
|
hbox->addWidget(g.wButtonB);
|
|
g.wButtonI = new QToolButton(central);
|
|
g.wButtonI->setText("&I");
|
|
g.wButtonI->setCheckable(true);
|
|
hbox->addWidget(g.wButtonI);
|
|
g.wButtonU = new QToolButton(central);
|
|
g.wButtonU->setText("&U");
|
|
g.wButtonU->setCheckable(true);
|
|
hbox->addWidget(g.wButtonU);
|
|
|
|
g.wStatus = new QLabel(central);
|
|
g.wStatus->setAlignment(
|
|
Qt::AlignRight | Qt::AlignTrailing | Qt::AlignVCenter);
|
|
hbox->addWidget(g.wStatus);
|
|
|
|
g.wButtonLog = new QToolButton(central);
|
|
g.wButtonLog->setText("&Log");
|
|
g.wButtonLog->setCheckable(true);
|
|
QObject::connect(g.wButtonLog, &QToolButton::clicked,
|
|
[]([[maybe_unused]] bool checked) { buffer_toggle_log(); });
|
|
hbox->addWidget(g.wButtonLog);
|
|
|
|
g.wButtonDown = new QToolButton(central);
|
|
g.wButtonDown->setIcon(
|
|
QApplication::style()->standardIcon(QStyle::SP_ArrowDown));
|
|
g.wButtonDown->setToolButtonStyle(Qt::ToolButtonIconOnly);
|
|
QObject::connect(g.wButtonDown, &QToolButton::clicked,
|
|
[]([[maybe_unused]] bool checked) { buffer_scroll_to_bottom(); });
|
|
hbox->addWidget(g.wButtonDown);
|
|
vbox->addLayout(hbox);
|
|
|
|
g.wInput = new InputEdit(central);
|
|
g.wInput->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
|
|
g.wInput->setMaximumHeight(50);
|
|
vbox->addWidget(g.wInput);
|
|
|
|
// TODO(p): Figure out why this is not reliable.
|
|
QObject::connect(g.wInput, &QTextEdit::currentCharFormatChanged,
|
|
[](const QTextCharFormat &format) {
|
|
g.wButtonB->setChecked(format.fontWeight() >= QFont::Bold);
|
|
g.wButtonI->setChecked(format.fontItalic());
|
|
g.wButtonU->setChecked(format.fontUnderline());
|
|
});
|
|
|
|
QObject::connect(g.wButtonB, &QToolButton::clicked,
|
|
[](bool checked) {
|
|
auto cursor = g.wInput->textCursor();
|
|
auto format = cursor.charFormat();
|
|
format.setFontWeight(checked ? QFont::Bold : QFont::Normal);
|
|
cursor.mergeCharFormat(format);
|
|
g.wInput->setTextCursor(cursor);
|
|
});
|
|
QObject::connect(g.wButtonI, &QToolButton::clicked,
|
|
[](bool checked) {
|
|
auto cursor = g.wInput->textCursor();
|
|
auto format = cursor.charFormat();
|
|
format.setFontItalic(checked);
|
|
cursor.mergeCharFormat(format);
|
|
g.wInput->setTextCursor(cursor);
|
|
});
|
|
QObject::connect(g.wButtonU, &QToolButton::clicked,
|
|
[](bool checked) {
|
|
auto cursor = g.wInput->textCursor();
|
|
auto format = cursor.charFormat();
|
|
format.setFontUnderline(checked);
|
|
cursor.mergeCharFormat(format);
|
|
g.wInput->setTextCursor(cursor);
|
|
});
|
|
|
|
central->setLayout(vbox);
|
|
g.wMain->setCentralWidget(central);
|
|
g.wMain->show();
|
|
}
|
|
|
|
static void
|
|
build_connect_dialog()
|
|
{
|
|
auto dialog = g.wConnectDialog = new QDialog(g.wMain);
|
|
dialog->setModal(true);
|
|
dialog->setWindowTitle("Connect to relay");
|
|
|
|
auto layout = new QFormLayout();
|
|
g.wConnectHost = new QLineEdit(dialog);
|
|
layout->addRow("&Host:", g.wConnectHost);
|
|
g.wConnectPort = new QLineEdit(dialog);
|
|
auto validator = new QIntValidator(0, 0xffff, g.wConnectDialog);
|
|
g.wConnectPort->setValidator(validator);
|
|
layout->addRow("&Port:", g.wConnectPort);
|
|
|
|
auto buttons = new QDialogButtonBox(dialog);
|
|
buttons->addButton(new QPushButton("&Connect", buttons),
|
|
QDialogButtonBox::AcceptRole);
|
|
buttons->addButton(new QPushButton("&Exit", buttons),
|
|
QDialogButtonBox::RejectRole);
|
|
QObject::connect(buttons, &QDialogButtonBox::accepted,
|
|
dialog, &QDialog::accept);
|
|
QObject::connect(buttons, &QDialogButtonBox::rejected,
|
|
dialog, &QDialog::reject);
|
|
|
|
auto vbox = new QVBoxLayout();
|
|
vbox->addLayout(layout);
|
|
vbox->addWidget(buttons);
|
|
dialog->setLayout(vbox);
|
|
}
|
|
|
|
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
static std::vector<size_t>
|
|
rotated_buffers()
|
|
{
|
|
std::vector<size_t> rotated(g.buffers.size());
|
|
size_t start = 0;
|
|
for (auto it = g.buffers.begin(); it != g.buffers.end(); ++it)
|
|
if (it->buffer_name == g.buffer_current) {
|
|
start = it - g.buffers.begin();
|
|
break;
|
|
}
|
|
for (auto &index : rotated)
|
|
index = ++start % g.buffers.size();
|
|
return rotated;
|
|
}
|
|
|
|
static void
|
|
bind_shortcuts()
|
|
{
|
|
auto previous_buffer = [] {
|
|
auto rotated = rotated_buffers();
|
|
if (rotated.size() > 0) {
|
|
size_t i = (rotated.back() ? rotated.back() : g.buffers.size()) - 1;
|
|
buffer_activate(g.buffers[i].buffer_name);
|
|
}
|
|
};
|
|
auto next_buffer = [] {
|
|
auto rotated = rotated_buffers();
|
|
if (rotated.size() > 0)
|
|
buffer_activate(g.buffers[rotated.front()].buffer_name);
|
|
};
|
|
auto switch_buffer = [] {
|
|
if (auto b = buffer_by_name(g.buffer_last))
|
|
buffer_activate(b->buffer_name);
|
|
};
|
|
auto goto_highlight = [] {
|
|
for (auto i : rotated_buffers())
|
|
if (g.buffers[i].highlighted) {
|
|
buffer_activate(g.buffers[i].buffer_name);
|
|
break;
|
|
}
|
|
};
|
|
auto goto_activity = [] {
|
|
for (auto i : rotated_buffers())
|
|
if (g.buffers[i].new_messages) {
|
|
buffer_activate(g.buffers[i].buffer_name);
|
|
break;
|
|
}
|
|
};
|
|
auto toggle_unimportant = [] {
|
|
if (auto b = buffer_by_name(g.buffer_current))
|
|
buffer_toggle_unimportant(b->buffer_name);
|
|
};
|
|
|
|
new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_Tab),
|
|
g.wMain, switch_buffer);
|
|
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Tab),
|
|
g.wMain, switch_buffer);
|
|
|
|
new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F5),
|
|
g.wMain, previous_buffer);
|
|
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageUp),
|
|
g.wMain, previous_buffer);
|
|
new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageUp),
|
|
g.wMain, previous_buffer);
|
|
new QShortcut(QKeyCombination(Qt::NoModifier, Qt::Key_F6),
|
|
g.wMain, next_buffer);
|
|
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_PageDown),
|
|
g.wMain, next_buffer);
|
|
new QShortcut(QKeyCombination(Qt::ControlModifier, Qt::Key_PageDown),
|
|
g.wMain, next_buffer);
|
|
|
|
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_A),
|
|
g.wMain, goto_activity);
|
|
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_Exclam),
|
|
g.wMain, goto_highlight);
|
|
new QShortcut(QKeyCombination(Qt::AltModifier, Qt::Key_H),
|
|
g.wMain, toggle_unimportant);
|
|
}
|
|
|
|
int
|
|
main(int argc, char *argv[])
|
|
{
|
|
QApplication app(argc, argv);
|
|
auto args = app.arguments();
|
|
if (args.size() != 1 && args.size() != 3) {
|
|
QMessageBox::critical(nullptr, "Error", "Usage: xT [HOST PORT]",
|
|
QMessageBox::Close);
|
|
return 1;
|
|
}
|
|
|
|
build_main_window();
|
|
build_connect_dialog();
|
|
bind_shortcuts();
|
|
|
|
g.date_change_timer = new QTimer(g.wMain);
|
|
g.date_change_timer->setSingleShot(true);
|
|
QObject::connect(g.date_change_timer, &QTimer::timeout, [] {
|
|
bool to_bottom = buffer_at_bottom();
|
|
buffer_print_and_watch_trailing_date_changes();
|
|
if (to_bottom)
|
|
buffer_scroll_to_bottom();
|
|
});
|
|
|
|
g.socket = new QTcpSocket(g.wMain);
|
|
QObject::connect(g.socket, &QTcpSocket::errorOccurred,
|
|
relay_process_error);
|
|
QObject::connect(g.socket, &QTcpSocket::connected,
|
|
relay_process_connected);
|
|
QObject::connect(g.socket, &QTcpSocket::readyRead,
|
|
relay_process_ready);
|
|
if (args.size() == 3) {
|
|
g.host = args[1];
|
|
g.port = args[2];
|
|
g.socket->connectToHost(g.host, g.port.toUShort());
|
|
} else {
|
|
// Allow it to center on its parent, which must be realized.
|
|
while (!g.wMain->windowHandle()->isExposed())
|
|
app.processEvents();
|
|
QTimer::singleShot(0, relay_show_dialog);
|
|
}
|
|
|
|
int result = app.exec();
|
|
delete g.wMain;
|
|
return result;
|
|
}
|
|
|
|
// Normally, QObjects should be placed in header files, which we don't do.
|
|
#include "xT.moc"
|