diff --git a/.gitignore b/.gitignore index 0595b084..e5aa1a81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build directory (out-of-tree build) /build/ +tools/chatgui/build/ # Build outputs *.o diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt new file mode 100644 index 00000000..37d10ebd --- /dev/null +++ b/tools/chatgui/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.16) +project(chatgui VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt5 COMPONENTS Widgets QUIET) +if(NOT Qt5_FOUND) + find_package(Qt6 COMPONENTS Widgets REQUIRED) + set(QT_LIBS Qt6::Widgets) +else() + set(QT_LIBS Qt5::Widgets) +endif() + +add_executable(chatgui + src/main.cpp + src/mainwindow.cpp + src/channellist.cpp + src/channeldelegate.cpp + src/chatview.cpp + src/messagedelegate.cpp + src/messagelist.cpp + src/accountlist.cpp +) + +target_link_libraries(chatgui PRIVATE ${QT_LIBS}) diff --git a/tools/chatgui/build.bat b/tools/chatgui/build.bat new file mode 100644 index 00000000..15c76363 --- /dev/null +++ b/tools/chatgui/build.bat @@ -0,0 +1,42 @@ +@echo off +pushd "%~dp0\..\.." + +echo ChatGUI Build Script for MSYS2 UCRT64 +echo =========================================== +echo. + +set MSYS2_PATH=C:\msys64 +if not "%MSYS2_ROOT%"=="" set MSYS2_PATH=%MSYS2_ROOT% +if not "%MSYS2%"=="" set MSYS2_PATH=%MSYS2% + +if not exist "%MSYS2_PATH%\msys2_shell.cmd" ( + echo [ERROR] MSYS2 not found at %MSYS2_PATH% + echo Please install MSYS2 from https://www.msys2.org/ + echo or set MSYS2_ROOT environment variable. + pause + exit /b 1 +) + +echo [INFO] MSYS2 found at: %MSYS2_PATH% +echo [INFO] Starting build... +echo. + +set "CURRENT_DIR=%CD%" + +call "%MSYS2_PATH%\msys2_shell.cmd" -ucrt64 -here -defterm -no-start -c "cd \"%CURRENT_DIR%/tools/chatgui\" && bash build.sh 2>&1 | tee build_win.log" + +set BUILD_RESULT=%ERRORLEVEL% + +echo. +echo =========================================== +if %BUILD_RESULT% == 0 ( + echo [SUCCESS] Build completed! + echo [INFO] Executable: tools\chatgui\build\chatgui.exe +) else ( + echo [FAILED] Build failed with error code: %BUILD_RESULT% + echo [INFO] Check tools\chatgui\build_win.log for details +) +echo =========================================== +echo. + +pause diff --git a/tools/chatgui/build.sh b/tools/chatgui/build.sh new file mode 100755 index 00000000..83d2794d --- /dev/null +++ b/tools/chatgui/build.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -eo pipefail + +LOG_FILE="build_win.log" +> "$LOG_FILE" + +log() { echo "$1"; echo "$1" >> "$LOG_FILE"; } + +log "================================" +log "ChatGUI Build Script (MSYS2 UCRT64)" +log "================================" +log "" + +SRC_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SRC_DIR/build" + +log "Source: $SRC_DIR" +log "Build: $BUILD_DIR" +log "" + +log "[1/3] Configuring CMake..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G "MSYS Makefiles" 2>&1 | tee -a "$LOG_FILE" +test ${PIPESTATUS[0]} -eq 0 || { log "CMake configure FAILED"; exit 1; } + +log "" +log "[2/3] Building..." +cmake --build "$BUILD_DIR" 2>&1 | tee -a "$LOG_FILE" +test ${PIPESTATUS[0]} -eq 0 || { log "Build FAILED"; exit 1; } + +log "" +log "[3/3] Verifying..." +EXE="$BUILD_DIR/chatgui.exe" +if [ -f "$EXE" ]; then + SIZE=$(du -h "$EXE" | cut -f1) + log " OK: chatgui.exe ($SIZE)" + log "" + log "================================" + log "Build SUCCESS" + log "================================" + log "Executable: $EXE" +else + log " FAIL: chatgui.exe not found" + exit 1 +fi diff --git a/tools/chatgui/resources/bg.jpg b/tools/chatgui/resources/bg.jpg new file mode 100644 index 00000000..5b95d805 Binary files /dev/null and b/tools/chatgui/resources/bg.jpg differ diff --git a/tools/chatgui/src/accountlist.cpp b/tools/chatgui/src/accountlist.cpp new file mode 100644 index 00000000..686ff7d3 --- /dev/null +++ b/tools/chatgui/src/accountlist.cpp @@ -0,0 +1,69 @@ +#include "accountlist.h" +#include +#include +#include +#include + +static QPixmap makeAvatar(const QColor &color, const QString &letter) { + QPixmap pix(24, 24); + pix.fill(Qt::transparent); + QPainter p(&pix); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(color); + p.setPen(Qt::NoPen); + p.drawEllipse(0, 0, 24, 24); + p.setPen(Qt::white); + QFont f = p.font(); + f.setBold(true); + f.setPointSize(10); + p.setFont(f); + p.drawText(QRect(0, 0, 24, 24), Qt::AlignCenter, letter); + p.end(); + return pix; +} + +AccountList::AccountList(QWidget *parent) + : QWidget(parent) + , m_listView(new QListView(this)) + , m_model(new QStandardItemModel(this)) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *header = new QLabel("Accounts", this); + header->setAlignment(Qt::AlignCenter); + header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); + layout->addWidget(header); + + m_listView->setModel(m_model); + m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_listView->setSelectionMode(QAbstractItemView::SingleSelection); + m_listView->setIconSize(QSize(24, 24)); + m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + layout->addWidget(m_listView); + + populateStubData(); +} + +void AccountList::populateStubData() { + struct { + const char *name; + QColor color; + const char *letter; + } accounts[] = { + {"Alice", QColor(74, 144, 226), "A"}, + {"Bob", QColor(126, 211, 33), "B"}, + {"Carol", QColor(245, 166, 35), "C"}, + {"Dave", QColor(144, 19, 254), "D"}, + {"Eve", QColor(0, 184, 148), "E"}, + }; + + for (const auto &a : accounts) { + auto *item = new QStandardItem(); + item->setText(a.name); + item->setIcon(QIcon(makeAvatar(a.color, a.letter))); + m_model->appendRow(item); + } +} diff --git a/tools/chatgui/src/accountlist.h b/tools/chatgui/src/accountlist.h new file mode 100644 index 00000000..7fc1cfc0 --- /dev/null +++ b/tools/chatgui/src/accountlist.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +class AccountList : public QWidget { + Q_OBJECT +public: + explicit AccountList(QWidget *parent = nullptr); + +private: + QListView *m_listView; + QStandardItemModel *m_model; + void populateStubData(); +}; diff --git a/tools/chatgui/src/channeldelegate.cpp b/tools/chatgui/src/channeldelegate.cpp new file mode 100644 index 00000000..daefcb56 --- /dev/null +++ b/tools/chatgui/src/channeldelegate.cpp @@ -0,0 +1,74 @@ +#include "channeldelegate.h" +#include +#include + +ChannelDelegate::ChannelDelegate(QObject *parent) + : QStyledItemDelegate(parent) {} + +void ChannelDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const { + painter->save(); + + if (option.state & QStyle::State_Selected) { + painter->fillRect(option.rect, option.palette.highlight()); + } else if (option.state & QStyle::State_MouseOver) { + painter->fillRect(option.rect, option.palette.light()); + } + + const int margin = 8; + const int iconSize = 32; + QRect iconRect(option.rect.left() + margin, + option.rect.top() + (option.rect.height() - iconSize) / 2, + iconSize, iconSize); + + QIcon icon = qvariant_cast(index.data(ChannelIconRole)); + if (!icon.isNull()) { + icon.paint(painter, iconRect); + } + + QRect textRect(iconRect.right() + margin, option.rect.top() + margin, + option.rect.right() - iconRect.right() - 2 * margin, + option.rect.height() - 2 * margin); + + QString name = index.data(ChannelNameRole).toString(); + QFont nameFont = option.font; + nameFont.setBold(true); + nameFont.setPointSize(nameFont.pointSize() + 1); + painter->setFont(nameFont); + + QColor textColor = (option.state & QStyle::State_Selected) + ? option.palette.highlightedText().color() + : option.palette.text().color(); + painter->setPen(textColor); + + QRect nameRect = textRect; + nameRect.setHeight(QFontMetrics(nameFont).height()); + painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignTop, + painter->fontMetrics().elidedText(name, Qt::ElideRight, nameRect.width())); + + QString lastMsg = index.data(ChannelLastMessageRole).toString(); + if (!lastMsg.isEmpty()) { + QFont msgFont = option.font; + msgFont.setPointSize(msgFont.pointSize() - 1); + painter->setFont(msgFont); + + QColor msgColor = (option.state & QStyle::State_Selected) + ? option.palette.highlightedText().color().darker(120) + : QColor(128, 128, 128); + painter->setPen(msgColor); + + QRect msgRect(textRect.left(), nameRect.bottom() + 4, + textRect.width(), option.rect.bottom() - nameRect.bottom() - margin); + QString elidedMsg = painter->fontMetrics().elidedText(lastMsg, Qt::ElideRight, msgRect.width()); + painter->drawText(msgRect, Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, elidedMsg); + } + + painter->restore(); +} + +QSize ChannelDelegate::sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const { + Q_UNUSED(option); + Q_UNUSED(index); + return QSize(200, 56); +} diff --git a/tools/chatgui/src/channeldelegate.h b/tools/chatgui/src/channeldelegate.h new file mode 100644 index 00000000..8bfa7324 --- /dev/null +++ b/tools/chatgui/src/channeldelegate.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +enum ChannelDataRole { + ChannelNameRole = Qt::DisplayRole, + ChannelIconRole = Qt::DecorationRole, + ChannelLastMessageRole = Qt::UserRole + 1 +}; + +class ChannelDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + explicit ChannelDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; +}; diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp new file mode 100644 index 00000000..7885b1da --- /dev/null +++ b/tools/chatgui/src/channellist.cpp @@ -0,0 +1,82 @@ +#include "channellist.h" +#include "channeldelegate.h" +#include +#include +#include +#include + +static QIcon makeChannelIcon(const QColor &color, const QString &letter) { + QPixmap pix(32, 32); + pix.fill(Qt::transparent); + QPainter p(&pix); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(color); + p.setPen(Qt::NoPen); + p.drawRoundedRect(0, 0, 32, 32, 8, 8); + p.setPen(Qt::white); + QFont f = p.font(); + f.setBold(true); + f.setPointSize(14); + p.setFont(f); + p.drawText(QRect(0, 0, 32, 32), Qt::AlignCenter, letter); + p.end(); + return QIcon(pix); +} + +ChannelList::ChannelList(QWidget *parent) + : QWidget(parent) + , m_listView(new QListView(this)) + , m_model(new QStandardItemModel(this)) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + auto *header = new QLabel("Channels", this); + header->setAlignment(Qt::AlignCenter); + header->setStyleSheet("font-weight: bold; padding: 8px; background: palette(window);"); + layout->addWidget(header); + + m_listView->setModel(m_model); + m_listView->setItemDelegate(new ChannelDelegate(this)); + m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_listView->setSelectionMode(QAbstractItemView::SingleSelection); + m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_listView->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + layout->addWidget(m_listView); + + connect(m_listView->selectionModel(), &QItemSelectionModel::currentChanged, + this, [this](const QModelIndex ¤t, const QModelIndex &) { + if (current.isValid()) { + emit channelSelected(current.row()); + } + }); + + populateStubData(); +} + +void ChannelList::populateStubData() { + struct { + const char *name; + const char *lastMsg; + QColor color; + const char *letter; + } channels[] = { + {"#general", "Alice: Sounds good, let's deploy tomorrow morning.", QColor(74, 144, 226), "G"}, + {"#random", "Bob: Anyone seen the latest episode? It was amazing!", QColor(126, 211, 33), "R"}, + {"#dev-team", "Carol: I've pushed the fix for ETCP-42 to staging.", QColor(245, 166, 35), "D"}, + {"#announcements","Admin: Maintenance scheduled for Sunday 02:00 UTC.", QColor(208, 2, 27), "A"}, + {"#support", "Dave: Can someone help me configure the routing table? The BGP session keeps dropping after the handshake completes. I've checked the logs.", QColor(144, 19, 254), "S"}, + {"#offtopic", "Eve: Check out this new keyboard — Cherry MX Blue, 60%.", QColor(0, 184, 148), "O"}, + }; + + for (const auto &ch : channels) { + auto *item = new QStandardItem(); + item->setData(ch.name, ChannelNameRole); + item->setData(QString::fromUtf8(ch.lastMsg), ChannelLastMessageRole); + item->setData(makeChannelIcon(ch.color, ch.letter), ChannelIconRole); + m_model->appendRow(item); + } + + m_listView->setCurrentIndex(m_model->index(0, 0)); +} diff --git a/tools/chatgui/src/channellist.h b/tools/chatgui/src/channellist.h new file mode 100644 index 00000000..6f81f9eb --- /dev/null +++ b/tools/chatgui/src/channellist.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include +#include + +class ChannelList : public QWidget { + Q_OBJECT +public: + explicit ChannelList(QWidget *parent = nullptr); + +signals: + void channelSelected(int index); + +private: + QListView *m_listView; + QStandardItemModel *m_model; + void populateStubData(); +}; diff --git a/tools/chatgui/src/chatview.cpp b/tools/chatgui/src/chatview.cpp new file mode 100644 index 00000000..cb4dbb68 --- /dev/null +++ b/tools/chatgui/src/chatview.cpp @@ -0,0 +1,34 @@ +#include "chatview.h" +#include +#include + +ChatView::ChatView(QWidget *parent) + : QListView(parent) +{ +} + +void ChatView::setBackgroundImage(const QString &path) { + if (!m_background.load(path)) { + qWarning() << "ChatView: failed to load background" << path; + return; + } + viewport()->update(); +} + +void ChatView::resizeEvent(QResizeEvent *event) { + QListView::resizeEvent(event); + scheduleDelayedItemsLayout(); +} + +void ChatView::scrollContentsBy(int dx, int dy) { + QListView::scrollContentsBy(dx, dy); + viewport()->update(); +} + +void ChatView::paintEvent(QPaintEvent *event) { + if (!m_background.isNull()) { + QPainter p(viewport()); + p.drawTiledPixmap(viewport()->rect(), m_background); + } + QListView::paintEvent(event); +} diff --git a/tools/chatgui/src/chatview.h b/tools/chatgui/src/chatview.h new file mode 100644 index 00000000..3de619b6 --- /dev/null +++ b/tools/chatgui/src/chatview.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +class ChatView : public QListView { + Q_OBJECT +public: + explicit ChatView(QWidget *parent = nullptr); + + void setBackgroundImage(const QString &path); + +protected: + void paintEvent(QPaintEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void scrollContentsBy(int dx, int dy) override; + +private: + QPixmap m_background; +}; diff --git a/tools/chatgui/src/main.cpp b/tools/chatgui/src/main.cpp new file mode 100644 index 00000000..5df4f1f9 --- /dev/null +++ b/tools/chatgui/src/main.cpp @@ -0,0 +1,13 @@ +#include +#include "mainwindow.h" + +int main(int argc, char *argv[]) { + QApplication app(argc, argv); + app.setApplicationName("Chat"); + app.setQuitOnLastWindowClosed(false); + + MainWindow w; + w.show(); + + return app.exec(); +} diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp new file mode 100644 index 00000000..88dcdd20 --- /dev/null +++ b/tools/chatgui/src/mainwindow.cpp @@ -0,0 +1,83 @@ +#include "mainwindow.h" +#include "channellist.h" +#include "messagelist.h" +#include "accountlist.h" +#include +#include +#include +#include + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , m_channelList(nullptr) + , m_messageList(nullptr) + , m_accountList(nullptr) + , m_trayIcon(nullptr) + , m_trayMenu(nullptr) +{ + setWindowTitle("Chat"); + resize(900, 600); + + setupUi(); + setupTray(); +} + +void MainWindow::setupUi() { + auto *splitter = new QSplitter(Qt::Horizontal, this); + + m_channelList = new ChannelList(splitter); + m_messageList = new MessageList(splitter); + m_accountList = new AccountList(splitter); + + splitter->addWidget(m_channelList); + splitter->addWidget(m_messageList); + splitter->addWidget(m_accountList); + + splitter->setStretchFactor(0, 2); + splitter->setStretchFactor(1, 5); + splitter->setStretchFactor(2, 1); + + splitter->setSizes({220, 480, 180}); + + setCentralWidget(splitter); +} + +void MainWindow::setupTray() { + m_trayIcon = new QSystemTrayIcon(this); + m_trayIcon->setIcon(style()->standardIcon(QStyle::SP_ComputerIcon)); + m_trayIcon->setToolTip("Chat"); + + m_trayMenu = new QMenu(this); + QAction *showAction = m_trayMenu->addAction("Show"); + connect(showAction, &QAction::triggered, this, [this]() { + show(); + raise(); + activateWindow(); + }); + + QAction *quitAction = m_trayMenu->addAction("Quit"); + connect(quitAction, &QAction::triggered, qApp, &QApplication::quit); + + m_trayIcon->setContextMenu(m_trayMenu); + + connect(m_trayIcon, &QSystemTrayIcon::activated, + this, &MainWindow::onTrayActivated); + + m_trayIcon->show(); +} + +void MainWindow::closeEvent(QCloseEvent *event) { + hide(); + m_trayIcon->showMessage("Chat", "Application minimized to tray.", + QSystemTrayIcon::Information, 2000); + event->ignore(); +} + +void MainWindow::onTrayActivated(QSystemTrayIcon::ActivationReason reason) { + if (reason == QSystemTrayIcon::Trigger || + reason == QSystemTrayIcon::DoubleClick) { + show(); + raise(); + activateWindow(); + } +} diff --git a/tools/chatgui/src/mainwindow.h b/tools/chatgui/src/mainwindow.h new file mode 100644 index 00000000..bc2401d3 --- /dev/null +++ b/tools/chatgui/src/mainwindow.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +class ChannelList; +class MessageList; +class AccountList; + +class MainWindow : public QMainWindow { + Q_OBJECT +public: + explicit MainWindow(QWidget *parent = nullptr); + ~MainWindow() override = default; + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void onTrayActivated(QSystemTrayIcon::ActivationReason reason); + +private: + void setupUi(); + void setupTray(); + + ChannelList *m_channelList; + MessageList *m_messageList; + AccountList *m_accountList; + QSystemTrayIcon *m_trayIcon; + QMenu *m_trayMenu; +}; diff --git a/tools/chatgui/src/messagedelegate.cpp b/tools/chatgui/src/messagedelegate.cpp new file mode 100644 index 00000000..ae0d5b4d --- /dev/null +++ b/tools/chatgui/src/messagedelegate.cpp @@ -0,0 +1,251 @@ +#include "messagedelegate.h" +#include +#include +#include +#include +#include + +MessageDelegate::MessageDelegate(QObject *parent) + : QStyledItemDelegate(parent) {} + +QSize MessageDelegate::sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const { + Layout L = calcLayout(option, index); + return QSize(L.viewWidth, L.totalHeight); +} + +static int measureTextHeight(const QFont &font, const QString &text, int width) { + if (text.isEmpty() || width <= 0) return 0; + QRect r = QFontMetrics(font).boundingRect(QRect(0, 0, width, 100000), + Qt::AlignLeft | Qt::TextWordWrap, text); + return r.height(); +} + +MessageDelegate::Layout MessageDelegate::calcLayout( + const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + Layout L{}; + L.viewWidth = 300; + if (option.widget) { + auto *v = qobject_cast(option.widget); + if (v) L.viewWidth = v->viewport()->width(); + } + if (L.viewWidth <= 0) L.viewWidth = 300; + + bool hasQuote = index.data(MsgHasQuoteRole).toBool(); + QString text = index.data(MsgTextRole).toString(); + QString qText = hasQuote ? index.data(MsgQuoteTextRole).toString() : QString(); + + int bubbleW = L.viewWidth - kMarginH - kAvatar - kGap - kMarginH; + if (bubbleW < 100) bubbleW = 100; + L.bubbleWidth = bubbleW; + + int textW = bubbleW - 2 * kPadH; + + int qH = 0; + if (hasQuote) { + int qTextW = bubbleW - 2 * kPadH - kQuoteBarW - kQuotePadH; + int qTextH = measureTextHeight(option.font, qText, qTextW); + int qOneLineH = QFontMetrics(option.font).height(); + qH = kQuotePadV + qOneLineH + (qText.isEmpty() ? 0 : 2 + qTextH) + kQuotePadV; + } + + int textH = measureTextHeight(option.font, text, textW); + int bubbleH = kPadTop + (hasQuote ? qH + kQuoteGap : 0) + textH + kStatusH + kPadBot; + L.totalHeight = qMax(bubbleH, kAvatar) + kItemGap; + + int contentLeft = kMarginH + kAvatar + kGap; + + L.bubbleRect = QRect(contentLeft, 0, bubbleW, bubbleH); + + int contentTop = kPadTop; + + if (hasQuote) { + L.quoteRect = QRect(contentLeft + kPadH, contentTop, bubbleW - 2 * kPadH, qH); + L.quoteBarRect = QRect(L.quoteRect.left(), L.quoteRect.top(), + kQuoteBarW, qH); + int tx = L.quoteBarRect.right() + kQuotePadH; + L.quoteAuthorRect = QRect(tx, L.quoteRect.top() + kQuotePadV, + L.quoteRect.width() - kQuoteBarW - kQuotePadH, + QFontMetrics(option.font).height()); + L.quoteTextRect = QRect(tx, + L.quoteAuthorRect.bottom() + 2, + L.quoteAuthorRect.width(), + qH - kQuotePadV - QFontMetrics(option.font).height() - 2 - kQuotePadV); + contentTop += qH + kQuoteGap; + } + + L.textRect = QRect(contentLeft + kPadH, contentTop, + textW, textH); + L.statusRect = QRect(contentLeft + kPadH, + contentTop + textH + 2, + textW, kStatusH); + + int avatarY = L.bubbleRect.bottom() - kAvatar; + L.avatarRect = QRect(kMarginH, avatarY, kAvatar, kAvatar); + + return L; +} + +void MessageDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const { + painter->save(); + painter->translate(option.rect.topLeft()); + painter->setClipRect(QRect(QPoint(0, 0), option.rect.size())); + + Layout L = calcLayout(option, index); + + drawBubble(painter, L); + + if (index.data(MsgHasQuoteRole).toBool()) { + drawQuote(painter, L, index, option); + } + + drawAvatar(painter, L, index); + + painter->setPen(option.palette.text().color()); + QFont textFont = option.font; + textFont.setPointSize(textFont.pointSize()); + painter->setFont(textFont); + painter->drawText(L.textRect, Qt::AlignLeft | Qt::TextWordWrap, + index.data(MsgTextRole).toString()); + + drawStatusBar(painter, L, index, option); + + painter->restore(); +} + +void MessageDelegate::drawQuote(QPainter *p, const Layout &L, + const QModelIndex &index, + const QStyleOptionViewItem &opt) const { + p->save(); + + p->setPen(Qt::NoPen); + p->setBrush(QColor(0xFF, 0xEE, 0xEE)); + p->drawRoundedRect(L.quoteRect, 5, 5); + + p->setPen(Qt::NoPen); + p->setBrush(QColor(0xE0, 0x40, 0x40)); + p->drawRect(L.quoteBarRect); + + QFont boldFont = opt.font; + boldFont.setBold(true); + p->setFont(boldFont); + p->setPen(QColor(0xE0, 0x40, 0x40)); + p->drawText(L.quoteAuthorRect, Qt::AlignLeft | Qt::AlignVCenter, + index.data(MsgQuoteAuthorRole).toString()); + + p->setFont(opt.font); + p->setPen(QColor(0x99, 0x55, 0x55)); + p->drawText(L.quoteTextRect, Qt::AlignLeft | Qt::TextWordWrap, + index.data(MsgQuoteTextRole).toString()); + + p->restore(); +} + +void MessageDelegate::drawBubble(QPainter *p, const Layout &L) const { + p->save(); + p->setRenderHint(QPainter::Antialiasing); + + QRectF b = L.bubbleRect; + qreal r = kRadius; + qreal t = kTail; + + QPainterPath body; + body.addRoundedRect(b, r, r); + + QPainterPath tail; + qreal tx = b.left(); + qreal ty = b.bottom(); + tail.moveTo(tx, ty - r); + tail.lineTo(tx - t, ty - t / 2.0); + tail.lineTo(tx + r, ty); + tail.closeSubpath(); + + QPainterPath combined = body.united(tail); + + p->setPen(QPen(QColor(0xE0, 0xE0, 0xE0), 0.5)); + p->setBrush(QColor(255, 255, 255)); + p->drawPath(combined); + + p->restore(); +} + +void MessageDelegate::drawStatusBar(QPainter *p, const Layout &L, + const QModelIndex &index, + const QStyleOptionViewItem &opt) const { + p->save(); + + QRect r = L.statusRect; + + QVariant reactionsVar = index.data(MsgReactionsRole); + QVariantList reactions = reactionsVar.toList(); + + int rx = r.left(); + if (!reactions.isEmpty()) { + for (const QVariant &rv : reactions) { + QVariantMap rm = rv.toMap(); + QString emoji = rm["emoji"].toString(); + int count = rm["count"].toInt(); + QVariant colorsVar = rm["colors"]; + QList colors = colorsVar.value>(); + + if (count < 4 && !colors.isEmpty()) { + int iconSize = 16; + for (int i = colors.size() - 1; i >= 0; --i) { + int cx = rx + i * 11; + p->setPen(Qt::NoPen); + p->setBrush(colors[i]); + p->drawEllipse(cx, r.top() + 1, iconSize, iconSize); + } + rx += colors.size() * 11 + 8; + } else { + QFont emojiFont = opt.font; + emojiFont.setPointSize(emojiFont.pointSize() - 1); + p->setFont(emojiFont); + p->setPen(opt.palette.text().color()); + QRect emojiRect(rx, r.top(), 20, r.height()); + p->drawText(emojiRect, Qt::AlignLeft | Qt::AlignVCenter, emoji); + rx += 20; + + QFont countFont = opt.font; + countFont.setPointSize(countFont.pointSize() - 2); + p->setFont(countFont); + p->setPen(QColor(0x80, 0x80, 0x80)); + QRect cntRect(rx, r.top(), 24, r.height()); + p->drawText(cntRect, Qt::AlignLeft | Qt::AlignVCenter, + QString::number(count)); + rx = cntRect.right() + 8; + } + } + } + + QFont smallFont = opt.font; + smallFont.setPointSize(smallFont.pointSize() - 2); + p->setFont(smallFont); + p->setPen(QColor(0x80, 0x80, 0x80)); + + QString statusText; + if (index.data(MsgEditedRole).toBool()) { + statusText = QString::fromUtf8("изменено "); + } + statusText += index.data(MsgTimeRole).toString(); + p->drawText(QRect(r.left(), r.top(), r.width(), r.height()), + Qt::AlignRight | Qt::AlignVCenter, statusText); + + p->restore(); +} + +void MessageDelegate::drawAvatar(QPainter *p, const Layout &L, + const QModelIndex &index) const { + QPixmap av = index.data(MsgAvatarRole).value(); + if (av.isNull()) return; + + p->save(); + p->setRenderHint(QPainter::Antialiasing); + QPainterPath clip; + clip.addEllipse(L.avatarRect); + p->setClipPath(clip); + p->drawPixmap(L.avatarRect, av); + p->restore(); +} diff --git a/tools/chatgui/src/messagedelegate.h b/tools/chatgui/src/messagedelegate.h new file mode 100644 index 00000000..68d33326 --- /dev/null +++ b/tools/chatgui/src/messagedelegate.h @@ -0,0 +1,67 @@ +#pragma once + +#include + +enum MessageDataRole { + MsgAuthorRole = Qt::UserRole + 1, + MsgTextRole = Qt::UserRole + 2, + MsgTimeRole = Qt::UserRole + 3, + MsgEditedRole = Qt::UserRole + 4, + MsgAvatarRole = Qt::UserRole + 5, + MsgHasQuoteRole = Qt::UserRole + 6, + MsgQuoteAuthorRole = Qt::UserRole + 7, + MsgQuoteTextRole = Qt::UserRole + 8, + MsgReactionsRole = Qt::UserRole + 9, +}; + +class MessageDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + explicit MessageDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, + const QModelIndex &index) const override; + +private: + struct Layout { + int viewWidth; + int bubbleWidth; + QRect avatarRect; + QRect bubbleRect; + QRect quoteRect; + QRect quoteBarRect; + QRect quoteAuthorRect; + QRect quoteTextRect; + QRect textRect; + QRect statusRect; + int totalHeight; + }; + + Layout calcLayout(const QStyleOptionViewItem &option, + const QModelIndex &index) const; + void drawQuote(QPainter *p, const Layout &L, + const QModelIndex &index, const QStyleOptionViewItem &opt) const; + void drawBubble(QPainter *p, const Layout &L) const; + void drawStatusBar(QPainter *p, const Layout &L, + const QModelIndex &index, const QStyleOptionViewItem &opt) const; + void drawAvatar(QPainter *p, const Layout &L, + const QModelIndex &index) const; + + static constexpr int kAvatar = 36; + static constexpr int kGap = 8; + static constexpr int kRadius = 10; + static constexpr int kPadH = 12; + static constexpr int kPadTop = 8; + static constexpr int kPadBot = 6; + static constexpr int kTail = 6; + static constexpr int kMarginH = 8; + static constexpr int kItemGap = 3; + static constexpr int kQuoteGap = 4; + static constexpr int kQuotePadH = 6; + static constexpr int kQuotePadV = 6; + static constexpr int kQuoteBarW = 2; + static constexpr int kQuoteAv = 16; + static constexpr int kStatusH = 18; +}; diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp new file mode 100644 index 00000000..5c18dc8e --- /dev/null +++ b/tools/chatgui/src/messagelist.cpp @@ -0,0 +1,179 @@ +#include "messagelist.h" +#include "chatview.h" +#include "messagedelegate.h" +#include +#include +#include + +static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) { + QPixmap pix(size, size); + pix.fill(Qt::transparent); + QPainter p(&pix); + p.setRenderHint(QPainter::Antialiasing); + p.setBrush(color); + p.setPen(Qt::NoPen); + p.drawEllipse(0, 0, size, size); + p.setPen(Qt::white); + QFont f = p.font(); + f.setBold(true); + f.setPointSize(size / 3); + p.setFont(f); + p.drawText(QRect(0, 0, size, size), Qt::AlignCenter, letter); + p.end(); + return pix; +} + +static QVariant makeReaction(const QString &emoji, int count, + const QList &colors = {}) { + QVariantMap m; + m["emoji"] = emoji; + m["count"] = count; + if (!colors.isEmpty()) + m["colors"] = QVariant::fromValue(colors); + return m; +} + +MessageList::MessageList(QWidget *parent) + : QWidget(parent) + , m_view(new ChatView(this)) + , m_model(new QStandardItemModel(this)) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_view->setModel(m_model); + m_view->setItemDelegate(new MessageDelegate(this)); + m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_view->setSelectionMode(QAbstractItemView::NoSelection); + m_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + m_view->setFrameShape(QFrame::NoFrame); + m_view->setBackgroundImage("../resources/bg.jpg"); + layout->addWidget(m_view); + + populateStubData(); +} + +static void setMsg(QStandardItem *item, + const QString &author, const QString &text, const QString &time, + const QPixmap &avatar, bool edited, + bool hasQuote, const QString &qAuthor, const QString &qText, + const QVariantList &reactions) { + item->setData(author, MsgAuthorRole); + item->setData(text, MsgTextRole); + item->setData(time, MsgTimeRole); + item->setData(edited, MsgEditedRole); + item->setData(QVariant::fromValue(avatar), MsgAvatarRole); + item->setData(hasQuote, MsgHasQuoteRole); + if (hasQuote) { + item->setData(qAuthor, MsgQuoteAuthorRole); + item->setData(qText, MsgQuoteTextRole); + } + if (!reactions.isEmpty()) + item->setData(reactions, MsgReactionsRole); +} + +void MessageList::populateStubData() { + QColor cAlice(74, 144, 226), cBob(126, 211, 33), cCarol(245, 166, 35); + QColor cDave(144, 19, 254), cEve(0, 184, 148); + auto avAlice = makeAvatar(cAlice, "A"); + auto avBob = makeAvatar(cBob, "B"); + auto avCarol = makeAvatar(cCarol, "C"); + auto avDave = makeAvatar(cDave, "D"); + + auto *m0 = new QStandardItem(); + setMsg(m0, + QString::fromUtf8("Alice"), + QString::fromUtf8("Hey team! How's the deployment going?"), + "10:15", avAlice, false, + false, QString(), QString(), + {}); + m_model->appendRow(m0); + + auto *m1 = new QStandardItem(); + setMsg(m1, + QString::fromUtf8("Bob"), + QString::fromUtf8("All green on staging. Running the final integration tests now."), + "10:17", avBob, false, + false, QString(), QString(), + QVariantList{makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8D"), 2, + {cAlice, cCarol})}); + m_model->appendRow(m1); + + auto *m2 = new QStandardItem(); + setMsg(m2, + QString::fromUtf8("Carol"), + QString::fromUtf8("I've pushed the ETCP-42 fix to staging. BGP handshake should be stable now. Please review the patch when you get a chance."), + "10:22", avCarol, true, + false, QString(), QString(), + QVariantList{ + makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8D"), 3, + {cAlice, cBob, cDave}), + makeReaction(QString::fromUtf8("\xE2\x9D\xA4\xEF\xB8\x8F"), 1, + {cAlice}) + }); + m_model->appendRow(m2); + + auto *m3 = new QStandardItem(); + setMsg(m3, + QString::fromUtf8("Dave"), + QString::fromUtf8("Wait — the BGP session is still flapping after the handshake. I'm seeing the keepalive timer fire but no route updates come through. Logs attached."), + "10:25", avDave, false, + true, + QString::fromUtf8("Carol"), + QString::fromUtf8("BGP handshake should be stable now"), + QVariantList{ + makeReaction(QString::fromUtf8("\xF0\x9F\x98\xAE"), 1, + {cBob}) + }); + m_model->appendRow(m3); + + auto *m4 = new QStandardItem(); + setMsg(m4, + QString::fromUtf8("Alice"), + QString::fromUtf8("Reviewed the patch. LGTM. Let's merge and deploy to production tomorrow morning."), + "10:28", avAlice, false, + false, QString(), QString(), + QVariantList{ + makeReaction(QString::fromUtf8("\xF0\x9F\x91\x8D"), 5), + makeReaction(QString::fromUtf8("\xF0\x9F\x8E\x89"), 2, + {cBob, cEve}) + }); + m_model->appendRow(m4); + + auto *m5 = new QStandardItem(); + setMsg(m5, + QString::fromUtf8("Bob"), + QString::fromUtf8("Can you share the full logs for the BGP session? I'll dig into the keepalive timer logic."), + "10:30", avBob, false, + true, + QString::fromUtf8("Dave"), + QString::fromUtf8("keepalive timer fire but no route updates"), + {}); + m_model->appendRow(m5); + + auto *m6 = new QStandardItem(); + setMsg(m6, + QString::fromUtf8("Carol"), + QString::fromUtf8("Sounds good. Let's coordinate in #dev-team. I'll prepare the release notes."), + "10:33", avCarol, false, + false, QString(), QString(), + {}); + m_model->appendRow(m6); + + auto *m7 = new QStandardItem(); + setMsg(m7, + QString::fromUtf8("Alice"), + QString::fromUtf8("Deploy scheduled for tomorrow 10:00 UTC. All features are frozen — no more merges to main."), + "10:35", avAlice, false, + false, QString(), QString(), + QVariantList{ + makeReaction(QString::fromUtf8("\xF0\x9F\x9A\x80"), 3, + {cBob, cCarol, cDave}), + makeReaction(QString::fromUtf8("\xE2\x9D\xA4\xEF\xB8\x8F"), 7) + }); + m_model->appendRow(m7); + + m_view->scrollToBottom(); +} diff --git a/tools/chatgui/src/messagelist.h b/tools/chatgui/src/messagelist.h new file mode 100644 index 00000000..cd37eae3 --- /dev/null +++ b/tools/chatgui/src/messagelist.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +class ChatView; + +class MessageList : public QWidget { + Q_OBJECT +public: + explicit MessageList(QWidget *parent = nullptr); + +private: + ChatView *m_view; + QStandardItemModel *m_model; + void populateStubData(); +};