Browse Source

tools/chatgui: Qt5 GUI chat app stub with tray, channel list, message bubbles

feature/x25519-migration
Evgeny 3 months ago
parent
commit
b667d6f352
  1. 1
      .gitignore
  2. 27
      tools/chatgui/CMakeLists.txt
  3. 42
      tools/chatgui/build.bat
  4. 44
      tools/chatgui/build.sh
  5. BIN
      tools/chatgui/resources/bg.jpg
  6. 69
      tools/chatgui/src/accountlist.cpp
  7. 16
      tools/chatgui/src/accountlist.h
  8. 74
      tools/chatgui/src/channeldelegate.cpp
  9. 20
      tools/chatgui/src/channeldelegate.h
  10. 82
      tools/chatgui/src/channellist.cpp
  11. 19
      tools/chatgui/src/channellist.h
  12. 34
      tools/chatgui/src/chatview.cpp
  13. 19
      tools/chatgui/src/chatview.h
  14. 13
      tools/chatgui/src/main.cpp
  15. 83
      tools/chatgui/src/mainwindow.cpp
  16. 32
      tools/chatgui/src/mainwindow.h
  17. 251
      tools/chatgui/src/messagedelegate.cpp
  18. 67
      tools/chatgui/src/messagedelegate.h
  19. 179
      tools/chatgui/src/messagelist.cpp
  20. 17
      tools/chatgui/src/messagelist.h

1
.gitignore vendored

@ -1,5 +1,6 @@
# Build directory (out-of-tree build)
/build/
tools/chatgui/build/
# Build outputs
*.o

27
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})

42
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

44
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

BIN
tools/chatgui/resources/bg.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

69
tools/chatgui/src/accountlist.cpp

@ -0,0 +1,69 @@
#include "accountlist.h"
#include <QVBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPixmap>
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);
}
}

16
tools/chatgui/src/accountlist.h

@ -0,0 +1,16 @@
#pragma once
#include <QWidget>
#include <QListView>
#include <QStandardItemModel>
class AccountList : public QWidget {
Q_OBJECT
public:
explicit AccountList(QWidget *parent = nullptr);
private:
QListView *m_listView;
QStandardItemModel *m_model;
void populateStubData();
};

74
tools/chatgui/src/channeldelegate.cpp

@ -0,0 +1,74 @@
#include "channeldelegate.h"
#include <QPainter>
#include <QApplication>
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<QIcon>(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);
}

20
tools/chatgui/src/channeldelegate.h

@ -0,0 +1,20 @@
#pragma once
#include <QStyledItemDelegate>
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;
};

82
tools/chatgui/src/channellist.cpp

@ -0,0 +1,82 @@
#include "channellist.h"
#include "channeldelegate.h"
#include <QVBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPixmap>
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 &current, 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));
}

19
tools/chatgui/src/channellist.h

@ -0,0 +1,19 @@
#pragma once
#include <QWidget>
#include <QListView>
#include <QStandardItemModel>
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();
};

34
tools/chatgui/src/chatview.cpp

@ -0,0 +1,34 @@
#include "chatview.h"
#include <QPainter>
#include <QDebug>
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);
}

19
tools/chatgui/src/chatview.h

@ -0,0 +1,19 @@
#pragma once
#include <QListView>
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;
};

13
tools/chatgui/src/main.cpp

@ -0,0 +1,13 @@
#include <QApplication>
#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();
}

83
tools/chatgui/src/mainwindow.cpp

@ -0,0 +1,83 @@
#include "mainwindow.h"
#include "channellist.h"
#include "messagelist.h"
#include "accountlist.h"
#include <QSplitter>
#include <QCloseEvent>
#include <QApplication>
#include <QStyle>
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();
}
}

32
tools/chatgui/src/mainwindow.h

@ -0,0 +1,32 @@
#pragma once
#include <QMainWindow>
#include <QSystemTrayIcon>
#include <QMenu>
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;
};

251
tools/chatgui/src/messagedelegate.cpp

@ -0,0 +1,251 @@
#include "messagedelegate.h"
#include <QPainter>
#include <QPainterPath>
#include <QApplication>
#include <QAbstractItemView>
#include <QFontMetrics>
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<const QAbstractItemView *>(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<QColor> colors = colorsVar.value<QList<QColor>>();
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<QPixmap>();
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();
}

67
tools/chatgui/src/messagedelegate.h

@ -0,0 +1,67 @@
#pragma once
#include <QStyledItemDelegate>
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;
};

179
tools/chatgui/src/messagelist.cpp

@ -0,0 +1,179 @@
#include "messagelist.h"
#include "chatview.h"
#include "messagedelegate.h"
#include <QVBoxLayout>
#include <QPainter>
#include <QPixmap>
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<QColor> &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();
}

17
tools/chatgui/src/messagelist.h

@ -0,0 +1,17 @@
#pragma once
#include <QWidget>
#include <QStandardItemModel>
class ChatView;
class MessageList : public QWidget {
Q_OBJECT
public:
explicit MessageList(QWidget *parent = nullptr);
private:
ChatView *m_view;
QStandardItemModel *m_model;
void populateStubData();
};
Loading…
Cancel
Save