20 changed files with 1089 additions and 0 deletions
@ -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}) |
||||
@ -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 |
||||
@ -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 |
||||
|
After Width: | Height: | Size: 84 KiB |
@ -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); |
||||
} |
||||
} |
||||
@ -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(); |
||||
}; |
||||
@ -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); |
||||
} |
||||
@ -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; |
||||
}; |
||||
@ -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 ¤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)); |
||||
} |
||||
@ -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(); |
||||
}; |
||||
@ -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); |
||||
} |
||||
@ -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; |
||||
}; |
||||
@ -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(); |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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; |
||||
}; |
||||
@ -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(); |
||||
} |
||||
@ -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; |
||||
}; |
||||
@ -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(); |
||||
} |
||||
@ -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…
Reference in new issue