Merge pull request 'feat(multiplayer): More QoL Improvements' (#93) from feat/more-qol-changes into main

Reviewed-on: https://git.citron-emu.org/Citron/Emulator/pulls/93
This commit is contained in:
Collecting
2026-01-12 07:19:51 +00:00
5 changed files with 481 additions and 145 deletions

View File

@@ -17,6 +17,7 @@
#include <QPainter>
#include <QTime>
#include <QUrl>
#include <QPushButton>
#include <QtConcurrent/QtConcurrentRun>
#include <QToolButton>
#include <QGridLayout>
@@ -164,9 +165,15 @@ public:
}
QVariant data(int role) const override {
// If compact mode is on, we tell the model to return no text
if (role == Qt::DisplayRole && QStandardItem::data(Qt::UserRole + 7).toBool()) {
return QVariant();
}
if (role != Qt::DisplayRole) {
return QStandardItem::data(role);
}
QString name;
const QString nickname = data(NicknameRole).toString();
const QString username = data(UsernameRole).toString();
@@ -190,24 +197,53 @@ public:
ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::ChatRoom>()) {
ui->setupUi(this);
// Setup the Emoji Button
QToolButton* emoji_button = new QToolButton(this);
emoji_button->setText(QStringLiteral("😀"));
emoji_button->setPopupMode(QToolButton::InstantPopup);
emoji_button->setToolButtonStyle(Qt::ToolButtonTextOnly);
emoji_button->setFixedSize(36, 30);
emoji_button->setAutoRaise(true);
emoji_button->setFixedSize(30, 30);
// Hide the arrow indicator and remove padding to ensure the emoji is dead-center
emoji_button->setStyleSheet(QStringLiteral("QToolButton::menu-indicator { image: none; } QToolButton { padding: 0px; }"));
emoji_button->setPopupMode(QToolButton::InstantPopup);
emoji_button->setStyleSheet(QStringLiteral(
"QToolButton { padding: 0px; margin: 0px; }"
"QToolButton::menu-indicator { image: none; width: 0px; }"
));
ui->horizontalLayout_3->insertWidget(1, emoji_button);
// Setup the Send Button
send_message = new QPushButton(QStringLiteral(""), this);
send_message->setObjectName(QStringLiteral("send_message"));
send_message->setFixedSize(40, 30);
send_message->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
// Rebuild Layout
ui->horizontalLayout_3->removeWidget(ui->chat_message);
ui->horizontalLayout_3->addWidget(ui->chat_message); // Index 0
ui->horizontalLayout_3->addWidget(emoji_button); // Index 1
ui->horizontalLayout_3->addWidget(send_message); // Index 2
ui->horizontalLayout_3->setStretch(0, 1);
ui->horizontalLayout_3->setStretch(1, 0);
ui->horizontalLayout_3->setStretch(2, 0);
connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat);
connect(send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat);
QMenu* emoji_menu = new QMenu(this);
QStringList emojis = {
QStringLiteral("😀"), QStringLiteral("😂"), QStringLiteral("🤣"), QStringLiteral("😊"), QStringLiteral("😎"),
QStringLiteral("🤔"), QStringLiteral("🤨"), QStringLiteral("😭"), QStringLiteral("😮"), QStringLiteral("💀"),
QStringLiteral("👍"), QStringLiteral("👎"), QStringLiteral("🔥"), QStringLiteral(""), QStringLiteral("❤️"),
QStringLiteral("🎉"), QStringLiteral("💯"), QStringLiteral("🚀"), QStringLiteral("🎮"), QStringLiteral("🕹️"),
QStringLiteral("👾"), QStringLiteral("🍄"), QStringLiteral("⭐️"), QStringLiteral("⚔️"), QStringLiteral("🛡️")
QStringLiteral("🤔"), QStringLiteral("🤨"), QStringLiteral("🙄"), QStringLiteral("🥺"), QStringLiteral("😭"),
QStringLiteral("😮"), QStringLiteral("🥳"), QStringLiteral("😴"), QStringLiteral("🤡"), QStringLiteral("💀"),
QStringLiteral("👀"), QStringLiteral("💤"), QStringLiteral("👑"), QStringLiteral("👻"), QStringLiteral("🥀"),
QStringLiteral("👍"), QStringLiteral("👎"), QStringLiteral("👏"), QStringLiteral("🙌"), QStringLiteral("🙏"),
QStringLiteral("🤝"), QStringLiteral("💪"), QStringLiteral("👋"), QStringLiteral("👊"), QStringLiteral("👌"),
QStringLiteral("🎮"), QStringLiteral("🕹️"), QStringLiteral("👾"), QStringLiteral("💻"), QStringLiteral("📱"),
QStringLiteral("🖱️"), QStringLiteral("⌨️"), QStringLiteral("🎧"), QStringLiteral("📺"), QStringLiteral("🔋"),
QStringLiteral("🔥"), QStringLiteral(""), QStringLiteral("❤️"), QStringLiteral("🎉"), QStringLiteral("💯"),
QStringLiteral("🚀"), QStringLiteral("🍄"), QStringLiteral("⭐️"), QStringLiteral("⚔️"), QStringLiteral("🛡️"),
QStringLiteral("💎"), QStringLiteral("💡"), QStringLiteral("💣"), QStringLiteral("📢"), QStringLiteral("🔔"),
QStringLiteral(""), QStringLiteral(""), QStringLiteral("⚠️"), QStringLiteral("🚫"), QStringLiteral("🌈"),
QStringLiteral("🌊"), QStringLiteral(""), QStringLiteral("🍃"), QStringLiteral("🐱"), QStringLiteral("🐉"),
QStringLiteral("🍋"), QStringLiteral("🏆"), QStringLiteral("🧂"), QStringLiteral("🍿"), QStringLiteral("🫠")
};
// Create a container widget for the grid
@@ -216,22 +252,22 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::C
grid_layout->setSpacing(2);
grid_layout->setContentsMargins(5, 5, 5, 5);
const int max_columns = 5;
const int max_columns = 7;
for (int i = 0; i < emojis.size(); ++i) {
const QString emoji = emojis[i];
QToolButton* btn = new QToolButton(grid_container);
btn->setText(emoji);
btn->setFixedSize(34, 30);
btn->setFixedSize(32, 30);
btn->setAutoRaise(true);
btn->setStyleSheet(QStringLiteral("font-size: 16px;"));
connect(btn, &QToolButton::clicked, [this, emoji, emoji_menu]() {
ui->chat_message->insert(emoji);
ui->chat_message->setFocus();
emoji_menu->close(); // Close the menu after picking
emoji_menu->close();
});
// Add to grid: row = i / columns, col = i % columns
grid_layout->addWidget(btn, i / max_columns, i % max_columns);
}
@@ -265,9 +301,14 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique<Ui::C
connect(ui->chat_history, &QTextEdit::customContextMenuRequested, this,
&ChatRoom::OnChatContextMenu);
connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat);
connect(send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat);
connect(ui->chat_message, &QLineEdit::textChanged, this, &ChatRoom::OnChatTextChanged);
connect(ui->send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat);
connect(ui->player_view, &QTreeView::doubleClicked, this, &ChatRoom::OnPlayerDoubleClicked);
ui->horizontalLayout_3->setStretch(0, 1);
ui->horizontalLayout_3->setStretch(1, 0);
ui->horizontalLayout_3->setStretch(2, 0);
send_message->setFixedSize(40, 30);
send_message->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
UpdateTheme();
}
@@ -345,12 +386,12 @@ void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) {
}
void ChatRoom::Disable() {
ui->send_message->setDisabled(true);
if (send_message) send_message->setDisabled(true);
ui->chat_message->setDisabled(true);
}
void ChatRoom::Enable() {
ui->send_message->setEnabled(true);
if (send_message) send_message->setEnabled(true);
ui->chat_message->setEnabled(true);
}
@@ -386,6 +427,9 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) {
}
AppendChatMessage(m.GetPlayerChatMessage(static_cast<u16>(player), show_timestamps, override_color));
// Trigger the 15-second border highlight for the person who just spoke
HighlightPlayer(chat.nickname);
}
}
@@ -468,44 +512,139 @@ void ChatRoom::OnSendChat() {
AppendChatMessage(m.GetPlayerChatMessage(static_cast<u16>(player), show_timestamps, override_color));
ui->chat_message->clear();
HighlightPlayer(nick);
}
}
void ChatRoom::UpdateIconDisplay() {
for (int row = 0; row < player_list->invisibleRootItem()->rowCount(); ++row) {
QStandardItem* item = player_list->invisibleRootItem()->child(row);
const std::string avatar_url = item->data(PlayerListItem::AvatarUrlRole).toString().toStdString();
QColor ChatRoom::GetPlayerColor(const std::string& nickname, int index) const {
if (color_overrides.count(nickname)) {
return QColor(QString::fromStdString(color_overrides.at(nickname)));
}
const bool is_dark = QIcon::themeName().contains(QStringLiteral("dark")) ||
QIcon::themeName().contains(QStringLiteral("midnight"));
QPixmap pixmap;
static constexpr std::array<const char*, 16> default_colors = {
"#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222",
"#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"};
static constexpr std::array<const char*, 16> dark_colors = {
"#559AD1", "#4EC9A8", "#D69D85", "#C6C923", "#B975B5", "#D81F1F", "#7EAE39", "#4F8733",
"#F7CD8A", "#6FCACF", "#CE4897", "#8A2BE2", "#D2691E", "#9ACD32", "#FF7F50", "#152ccd"};
return QColor(is_dark ? dark_colors[index % 16] : default_colors[index % 16]);
}
void ChatRoom::UpdateIconDisplay() {
// 1. Determine canvas size based on mode
int canvas_w, canvas_h;
if (is_compact_mode) {
canvas_w = std::max(80, ui->player_view->viewport()->width() - 2);
canvas_h = 80; // Enough for avatar + name below
} else {
canvas_w = 54; // Just enough for 44px avatar + 4px border padding
canvas_h = 54;
}
const QSize canvas_size(canvas_w, canvas_h);
ui->player_view->setIconSize(canvas_size);
for (int row = 0; row < player_list->rowCount(); ++row) {
QStandardItem* item = player_list->item(row);
if (!item) continue;
const QString nickname = item->data(PlayerListItem::NicknameRole).toString();
const std::string nickname_std = nickname.toStdString();
const std::string avatar_url = item->data(PlayerListItem::AvatarUrlRole).toString().toStdString();
const QString game = item->data(PlayerListItem::GameNameRole).toString();
const QString version = item->data(PlayerListItem::GameVersionRole).toString();
item->setData(is_compact_mode, Qt::UserRole + 7);
QPixmap avatar_pixmap;
if (icon_cache.count(avatar_url)) {
pixmap = icon_cache.at(avatar_url);
avatar_pixmap = icon_cache.at(avatar_url);
} else {
pixmap = QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48);
avatar_pixmap = QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48);
}
QPixmap canvas = pixmap.copy();
QPixmap canvas(canvas_size);
canvas.fill(Qt::transparent);
QPainter painter(&canvas);
painter.setRenderHint(QPainter::Antialiasing);
painter.setRenderHint(QPainter::TextAntialiasing);
const int avatar_size = 44;
// Center for Compact, Left-Align for Regular
int avatar_x = is_compact_mode ? (canvas.width() - avatar_size) / 2 : 5;
int avatar_y = is_compact_mode ? 4 : 5;
// --- Draw Fading Border ---
float opacity = 0.0f;
if (highlight_states.count(nickname_std)) {
opacity = highlight_states[nickname_std].opacity;
}
if (opacity > 0.0f) {
QColor border_color = GetPlayerColor(nickname_std, row);
border_color.setAlphaF(opacity);
painter.setPen(QPen(border_color, 4));
painter.drawEllipse(avatar_x, avatar_y, avatar_size, avatar_size);
} else {
painter.setPen(QPen(QColor(255, 255, 255, 30), 1));
painter.drawEllipse(avatar_x, avatar_y, avatar_size, avatar_size);
}
// --- Draw Avatar ---
QPainterPath path;
path.addEllipse(avatar_x + 2, avatar_y + 2, 40, 40);
painter.setClipPath(path);
painter.drawPixmap(avatar_x + 2, avatar_y + 2, 40, 40, avatar_pixmap);
painter.setClipping(false);
// --- Draw Status Dot ---
QString dot_type = item->data(PlayerListItem::StatusDotRole).toString();
QColor dot_color;
if (dot_type == QStringLiteral("🟢")) dot_color = Qt::green;
else if (dot_type == QStringLiteral("🟡")) dot_color = Qt::yellow;
else dot_color = Qt::gray;
// Draw a small "outline" circle
painter.setBrush(QColor(30, 30, 30));
QColor dot_color = (dot_type == QStringLiteral("🟢")) ? Qt::green :
(dot_type == QStringLiteral("🟡")) ? Qt::yellow : Qt::gray;
painter.setPen(Qt::NoPen);
painter.drawEllipse(32, 32, 14, 14);
// Draw the actual status dot
painter.setBrush(QColor(30, 30, 30));
painter.drawEllipse(avatar_x + 30, avatar_y + 30, 12, 12);
painter.setBrush(dot_color);
painter.drawEllipse(34, 34, 10, 10);
painter.drawEllipse(avatar_x + 32, avatar_y + 32, 8, 8);
if (is_compact_mode) {
QFont font = painter.font();
int point_size = 9;
font.setBold(true);
font.setPointSize(point_size);
painter.setFont(font);
int text_width_limit = canvas.width() - 4;
while (painter.fontMetrics().horizontalAdvance(nickname) > text_width_limit && point_size > 6) {
point_size--;
font.setPointSize(point_size);
painter.setFont(font);
}
QString elided_name = painter.fontMetrics().elidedText(nickname, Qt::ElideRight, text_width_limit);
QRect text_rect(0, avatar_y + avatar_size + 2, canvas.width(), 20);
painter.setPen(QColor(0, 0, 0, 160));
painter.drawText(text_rect.adjusted(1, 1, 1, 1), Qt::AlignCenter, elided_name);
painter.setPen(UISettings::IsDarkTheme() ? Qt::white : Qt::black);
painter.drawText(text_rect, Qt::AlignCenter, elided_name);
}
painter.end();
// Set the final icon
item->setData(canvas, Qt::DecorationRole);
// Tooltip logic
QString display_game = version.isEmpty() ? game : QStringLiteral("%1 (%2)").arg(game, version);
item->setToolTip(tr("<b>%1</b><br>%2").arg(nickname, display_game));
if (is_compact_mode) {
item->setText(QString());
}
}
}
@@ -582,12 +721,33 @@ void ChatRoom::OnChatTextChanged() {
}
void ChatRoom::PopupContextMenu(const QPoint& menu_location) {
QModelIndex item = ui->player_view->indexAt(menu_location);
if (!item.isValid()) return;
std::string nickname = player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString();
QMenu context_menu;
// 1. Vertical Scrollbar Toggle
QAction* scroll_action = context_menu.addAction(tr("Hide Member Scrollbar"));
scroll_action->setCheckable(true);
scroll_action->setChecked(member_scrollbar_hidden);
connect(scroll_action, &QAction::triggered, [this](bool checked) {
member_scrollbar_hidden = checked;
ui->player_view->setVerticalScrollBarPolicy(checked ? Qt::ScrollBarAlwaysOff : Qt::ScrollBarAsNeeded);
if (is_compact_mode) {
ui->player_view->setFixedWidth(checked ? 90 : 110);
UpdateIconDisplay();
}
});
context_menu.addSeparator();
QModelIndex item = ui->player_view->indexAt(menu_location);
if (!item.isValid()) {
// If clicking empty space, just show the scrollbar toggle and exit
context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location));
return;
}
// 2. Player-specific options (Only shows if you click a name)
std::string nickname = player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString();
QAction* color_action = context_menu.addAction(tr("Set Name Color"));
connect(color_action, &QAction::triggered, [this, nickname] {
QColor color = QColorDialog::getColor(Qt::white, this, tr("Select Color for %1").arg(QString::fromStdString(nickname)));
@@ -643,15 +803,21 @@ void ChatRoom::UpdateTheme() {
const QString accent_color = Theme::GetAccentColor();
if (UISettings::IsDarkTheme()) {
style_sheet = QStringLiteral(R"(
QListView, QTextEdit, QLineEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; border-radius: 4px; }
QListView, QTextEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; border-radius: 4px; }
QListView::item:selected { background-color: %1; }
QPushButton { background-color: #3E3E3E; color: #E0E0E0; border: 1px solid #5A5A5A; padding: 5px; border-radius: 4px; }
QLineEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; padding-left: 5px; border-radius: 4px; }
QPushButton { background-color: #3E3E3E; color: #E0E0E0; border: 1px solid #5A5A5A; padding: 2px; border-radius: 4px; }
QPushButton#send_message { padding: 0px; margin: 0px; min-width: 40px; max-width: 40px; }
QToolButton { padding: 0px; margin: 0px; font-size: 14px; border: none; }
)").arg(accent_color);
} else {
style_sheet = QStringLiteral(R"(
QListView, QTextEdit, QLineEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; border-radius: 4px; }
QListView, QTextEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; border-radius: 4px; }
QListView::item:selected { background-color: %1; }
QPushButton { background-color: #F0F0F0; color: #000000; border: 1px solid #BDBDBD; padding: 5px; border-radius: 4px; }
QLineEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; padding-left: 5px; border-radius: 4px; }
QPushButton { background-color: #F0F0F0; color: #000000; border: 1px solid #BDBDBD; padding: 2px; border-radius: 4px; }
QPushButton#send_message { padding: 0px; margin: 0px; min-width: 40px; max-width: 40px; }
QToolButton { padding: 0px; margin: 0px; font-size: 14px; border: none; }
)").arg(accent_color);
}
this->setStyleSheet(style_sheet);
@@ -660,9 +826,47 @@ void ChatRoom::UpdateTheme() {
void ChatRoom::OnChatContextMenu(const QPoint& menu_location) {
QMenu* context_menu = ui->chat_history->createStandardContextMenu(menu_location);
context_menu->addSeparator();
QAction* clear_action = context_menu->addAction(tr("Clear Chat History"));
connect(clear_action, &QAction::triggered, this, &ChatRoom::Clear);
QAction* compact_action = context_menu->addAction(tr("Compact Member List"));
compact_action->setCheckable(true);
compact_action->setChecked(is_compact_mode);
connect(compact_action, &QAction::triggered, [this](bool checked) {
this->is_compact_mode = checked;
if (checked) {
int view_w = member_scrollbar_hidden ? 90 : 110;
ui->player_view->setFixedWidth(view_w);
ui->player_view->setIndentation(0);
ui->player_view->setHeaderHidden(true);
ui->player_view->setRootIsDecorated(false);
ui->player_view->header()->setSectionResizeMode(0, QHeaderView::Stretch);
ui->player_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
ui->player_view->setStyleSheet(QStringLiteral("QTreeView::item { padding: 0px; }"));
} else {
ui->player_view->setMinimumWidth(160);
ui->player_view->setMaximumWidth(1000);
ui->player_view->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
ui->player_view->setIndentation(20);
ui->player_view->setHeaderHidden(false);
ui->player_view->setRootIsDecorated(true);
ui->player_view->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
ui->player_view->header()->setStretchLastSection(false);
ui->player_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
ui->player_view->setStyleSheet(QString());
}
UpdateIconDisplay();
// Refresh player list to restore text in regular mode
if (room_network) {
if (auto room = room_network->GetRoomMember().lock()) {
SetPlayerList(room->GetMemberInformation());
}
}
});
QAction* mute_action = context_menu->addAction(tr("Hide Future Messages"));
mute_action->setCheckable(true);
mute_action->setChecked(chat_muted);
@@ -692,3 +896,80 @@ void ChatRoom::OnPlayerDoubleClicked(const QModelIndex& index) {
ui->chat_message->setFocus();
}
}
void ChatRoom::HighlightPlayer(const std::string& nickname) {
auto& state = highlight_states[nickname];
// 1. Clean up existing animations/timers
// QPointer automatically becomes null if the animation was already deleted
if (state.animation) {
state.animation->stop();
state.animation->deleteLater();
}
if (state.linger_timer) {
state.linger_timer->stop();
state.linger_timer->deleteLater();
state.linger_timer = nullptr;
}
// 2. Create Fade-In Animation
auto* fadeIn = new QVariantAnimation(this);
state.animation = fadeIn;
fadeIn->setDuration(400);
fadeIn->setStartValue(state.opacity);
fadeIn->setEndValue(1.0f);
fadeIn->setEasingCurve(QEasingCurve::OutQuad);
connect(fadeIn, &QVariantAnimation::valueChanged, [this, nickname](const QVariant& value) {
if (highlight_states.count(nickname)) {
highlight_states[nickname].opacity = value.toFloat();
UpdateIconDisplay();
}
});
connect(fadeIn, &QVariantAnimation::finished, [this, nickname]() {
if (!highlight_states.count(nickname)) return;
auto& s1 = highlight_states[nickname];
// Cleanup the finished animation
if (s1.animation) s1.animation->deleteLater();
s1.linger_timer = new QTimer(this);
s1.linger_timer->setSingleShot(true);
connect(s1.linger_timer, &QTimer::timeout, [this, nickname]() {
if (!highlight_states.count(nickname)) return;
auto& s2 = highlight_states[nickname];
auto* fadeOut = new QVariantAnimation(this);
s2.animation = fadeOut;
fadeOut->setDuration(400);
fadeOut->setStartValue(1.0f);
fadeOut->setEndValue(0.0f);
fadeOut->setEasingCurve(QEasingCurve::OutQuad);
connect(fadeOut, &QVariantAnimation::valueChanged, [this, nickname](const QVariant& value) {
if (highlight_states.count(nickname)) {
highlight_states[nickname].opacity = value.toFloat();
UpdateIconDisplay();
}
});
connect(fadeOut, &QVariantAnimation::finished, [this, nickname]() {
if (highlight_states.count(nickname)) {
auto& final_state = highlight_states[nickname];
if (final_state.animation) final_state.animation->deleteLater();
highlight_states.erase(nickname);
}
UpdateIconDisplay();
});
fadeOut->start();
});
s1.linger_timer->start(10000);
});
fadeIn->start();
}

View File

@@ -4,28 +4,33 @@
#pragma once
#include <chrono> // time tracking
#include <vector> // storing timestamps
#include <chrono>
#include <memory>
#include <unordered_map>
#include <unordered_set>
#include <QDialog>
#include <QSortFilterProxyModel>
#include <vector>
#include <QAbstractAnimation>
#include <QEasingCurve>
#include <QPointer>
#include <QStandardItemModel>
#include <QVariant>
#include <QTimer>
#include <QVariantAnimation>
#include <QWidget>
#include "network/network.h"
namespace Ui {
class ChatRoom;
class ChatRoom;
}
namespace Core {
class AnnounceMultiplayerSession;
class AnnounceMultiplayerSession;
}
class QPushButton;
class ConnectionError;
class ComboBoxProxyModel;
class ChatMessage;
class ChatRoom : public QWidget {
@@ -33,14 +38,14 @@ class ChatRoom : public QWidget {
public:
explicit ChatRoom(QWidget* parent);
~ChatRoom();
void Initialize(Network::RoomNetwork* room_network);
void Shutdown();
void RetranslateUi();
void SetPlayerList(const Network::RoomMember::MemberList& member_list);
void Clear();
void AppendStatusMessage(const QString& msg);
~ChatRoom();
void SetModPerms(bool is_mod);
void UpdateIconDisplay();
@@ -63,20 +68,35 @@ signals:
void UserPinged();
private:
static constexpr u32 max_chat_lines = 1000;
void AppendChatMessage(const QString&);
bool ValidateMessage(const std::string&);
void SendModerationRequest(Network::RoomMessageTypes type, const std::string& nickname);
QColor GetPlayerColor(const std::string& nickname, int index) const;
void HighlightPlayer(const std::string& nickname);
QPushButton* send_message = nullptr;
static constexpr u32 max_chat_lines = 1000;
bool has_mod_perms = false;
QStandardItemModel* player_list;
std::unique_ptr<Ui::ChatRoom> ui;
std::unordered_set<std::string> block_list;
std::unordered_map<std::string, QPixmap> icon_cache;
std::unordered_map<std::string, std::string> color_overrides;
// Highlight tracking with smooth fade-in/out
struct HighlightState {
float opacity = 0.0f;
QPointer<QVariantAnimation> animation;
QTimer* linger_timer = nullptr;
};
std::unordered_map<std::string, HighlightState> highlight_states;
bool is_compact_mode = false;
bool member_scrollbar_hidden = false;
bool chat_muted = false;
bool show_timestamps = true;
Network::RoomNetwork* room_network = nullptr;
std::vector<std::chrono::steady_clock::time_point> sent_message_timestamps;
static constexpr size_t MAX_MESSAGES_PER_INTERVAL = 3;
static constexpr std::chrono::seconds THROTTLE_INTERVAL{5};

View File

@@ -37,14 +37,7 @@
<item>
<widget class="QLineEdit" name="chat_message">
<property name="placeholderText">
<string>Send Chat Message</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="send_message">
<property name="text">
<string>Send Message</string>
<string>Type Here...</string>
</property>
</widget>
</item>

View File

@@ -1,8 +1,11 @@
// SPDX-FileCopyrightText: Copyright 2017 Citra Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QDialog>
#include <QWidget>
#include "citron/multiplayer/chat_room.h"
namespace Ui {

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QApplication>
#include <QGraphicsDropShadowEffect>
#include <QPainter>
#include <QPainterPath>
#include <QScreen>
@@ -23,24 +24,25 @@ MultiplayerRoomOverlay::MultiplayerRoomOverlay(QWidget* parent)
main_window = qobject_cast<GMainWindow*>(parent->window());
// Switched to Qt::Tool to allow keyboard focus for typing in chat
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint);
setAttribute(Qt::WA_TranslucentBackground);
// Set smaller sizes for Steam Deck
setMinimumSize(240, 180);
resize(260, 220);
main_layout = new QGridLayout(this);
main_layout->setContentsMargins(padding, padding, 0, 0);
main_layout->setSpacing(6);
main_layout->setContentsMargins(padding, padding, padding, padding);
main_layout->setSpacing(8);
players_online_label = new QLabel(this);
QGraphicsDropShadowEffect* shadow = new QGraphicsDropShadowEffect(this);
shadow->setBlurRadius(6);
shadow->setColor(Qt::black);
shadow->setOffset(0, 0);
players_online_label->setGraphicsEffect(shadow);
chat_room_widget = new ChatRoom(this);
size_grip = new QSizeGrip(this);
players_online_label->setFont(QFont(QString::fromUtf8("Segoe UI"), 10, QFont::Bold));
players_online_label->setText(QString::fromUtf8("Players Online: 0"));
players_online_label->setFont(QFont(QString::fromUtf8("Segoe UI"), 12, QFont::Bold));
players_online_label->setAttribute(Qt::WA_TransparentForMouseEvents, true);
size_grip->setFixedSize(16, 16);
@@ -66,21 +68,19 @@ MultiplayerRoomOverlay::MultiplayerRoomOverlay(QWidget* parent)
if (main_window) {
connect(main_window, &GMainWindow::themeChanged, this, &MultiplayerRoomOverlay::UpdateTheme);
connect(main_window, &GMainWindow::EmulationStarting, this, &MultiplayerRoomOverlay::OnEmulationStarting);
connect(main_window, &GMainWindow::EmulationStopping, this, &MultiplayerRoomOverlay::OnEmulationStopping);
}
UpdateTheme();
const bool is_gamescope = UISettings::IsGamescope();
if (is_gamescope) {
setMinimumSize(320, 260);
resize(600, 520);
players_online_label->setFont(QFont(QString::fromUtf8("Segoe UI"), 11, QFont::Bold));
this->padding = 12;
main_layout->setContentsMargins(padding, padding, padding, padding);
setMinimumSize(450, 350);
resize(700, 550);
this->padding = 15;
} else {
setMinimumSize(280, 220);
resize(320, 280);
setMinimumSize(360, 260);
resize(420, 300);
}
UpdatePosition();
@@ -91,17 +91,30 @@ MultiplayerRoomOverlay::~MultiplayerRoomOverlay() {
}
void MultiplayerRoomOverlay::OnEmulationStarting() {
// The connection is now managed by the overlay's visibility.
// Force a UI refresh immediately when a game starts
UpdateRoomData();
}
void MultiplayerRoomOverlay::OnEmulationStopping() {
// The connection should persist even when emulation stops.
update_timer.stop();
if (room_member && room_member->IsConnected()) {
// Only send if the state is stable
room_member->SendGameInfo({});
}
// Clear the UI text but don't force a full room data poll yet
players_online_label->setText(tr("Emulation Stopped."));
// Resume polling after 1 second once the LDN service has safely detached
QTimer::singleShot(1000, this, [this] {
if (is_visible) update_timer.start(500);
});
}
void MultiplayerRoomOverlay::SetVisible(bool visible) {
if (is_visible == visible) return;
is_visible = visible;
if (visible) {
show();
ConnectToRoom();
@@ -117,21 +130,36 @@ void MultiplayerRoomOverlay::paintEvent(QPaintEvent* event) {
Q_UNUSED(event)
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
QRect background_rect = rect();
// Move the top of the box down so the text floats above it
int label_area_height = players_online_label->height() + main_layout->spacing() + padding;
background_rect.setTop(label_area_height);
QPainterPath background_path;
background_path.addRoundedRect(rect(), corner_radius, corner_radius);
background_path.addRoundedRect(background_rect, corner_radius, corner_radius);
painter.fillPath(background_path, background_color);
painter.setPen(QPen(border_color, border_width));
painter.drawPath(background_path);
}
void MultiplayerRoomOverlay::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); if (!has_been_moved) UpdatePosition(); }
bool MultiplayerRoomOverlay::eventFilter(QObject* watched, QEvent* event) { if (event->type() == QEvent::MouseButtonPress) { if (chat_room_widget->hasFocus()) { chat_room_widget->clearFocus(); } } return QObject::eventFilter(watched, event); }
void MultiplayerRoomOverlay::resizeEvent(QResizeEvent* event) {
QWidget::resizeEvent(event);
if (!has_been_moved) UpdatePosition();
}
bool MultiplayerRoomOverlay::eventFilter(QObject* watched, QEvent* event) {
if (event->type() == QEvent::MouseButtonPress) {
if (chat_room_widget->hasFocus()) chat_room_widget->clearFocus();
}
return QObject::eventFilter(watched, event);
}
void MultiplayerRoomOverlay::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton && !size_grip->geometry().contains(event->pos())) {
const bool is_gamescope = !qgetenv("GAMESCOPE_WIDTH").isEmpty() || qgetenv("XDG_CURRENT_DESKTOP") == "gamescope";
if (is_gamescope) {
if (UISettings::IsGamescope()) {
is_dragging = true;
drag_start_pos = event->globalPosition().toPoint() - this->pos();
setCursor(Qt::ClosedHandCursor);
@@ -171,36 +199,27 @@ void MultiplayerRoomOverlay::ConnectToRoom() {
if (!main_window) return;
multiplayer_state = main_window->GetMultiplayerState();
if (!multiplayer_state) return;
if (multiplayer_state->IsClientRoomVisible()) {
chat_room_widget->setEnabled(false);
chat_room_widget->Clear();
chat_room_widget->AppendStatusMessage(tr("In order to use chat functionality in the Overlay, please close the Multiplayer Room Window."));
chat_room_widget->AppendStatusMessage(tr("Please close the Multiplayer Room Window to use the Overlay."));
return;
}
chat_room_widget->setEnabled(true);
auto& room_network = multiplayer_state->GetRoomNetwork();
room_member = room_network.GetRoomMember().lock();
if (room_member) {
if (!is_chat_initialized) {
chat_room_widget->Initialize(&room_network);
is_chat_initialized = true;
}
} else {
chat_room_widget->Clear();
chat_room_widget->AppendStatusMessage(tr("Not connected to a room."));
ClearUI();
}
}
void MultiplayerRoomOverlay::DisconnectFromRoom() {
// Tell the chat widget to disconnect its signals *before*
// we reset our own state.
if (is_chat_initialized && chat_room_widget) {
chat_room_widget->Shutdown();
}
if (is_chat_initialized && chat_room_widget) chat_room_widget->Shutdown();
ClearUI();
room_member.reset();
multiplayer_state = nullptr;
@@ -208,70 +227,90 @@ void MultiplayerRoomOverlay::DisconnectFromRoom() {
}
void MultiplayerRoomOverlay::ClearUI() {
players_online_label->setText(QString::fromUtf8("Players Online: 0"));
players_online_label->setText(tr("Not connected to a room."));
chat_room_widget->Clear();
chat_room_widget->AppendStatusMessage(tr("Not connected to a room."));
chat_room_widget->SetPlayerList({});
}
void MultiplayerRoomOverlay::UpdateRoomData() {
if (!multiplayer_state) {
ConnectToRoom();
return;
}
if (multiplayer_state->IsClientRoomVisible()) {
if (chat_room_widget->isEnabled()) {
chat_room_widget->setEnabled(false);
chat_room_widget->Clear();
chat_room_widget->AppendStatusMessage(tr("In order to use chat functionality in the Overlay, please close the Multiplayer Room Window."));
}
return;
}
if (!chat_room_widget->isEnabled()) {
ConnectToRoom();
}
if (!multiplayer_state) { ConnectToRoom(); return; }
if (multiplayer_state->IsClientRoomVisible()) { chat_room_widget->setEnabled(false); return; }
if (!chat_room_widget->isEnabled()) ConnectToRoom();
if (room_member && room_member->GetState() >= Network::RoomMember::State::Joined) {
const auto& members = room_member->GetMemberInformation();
QString label_text = QString::fromStdString("Players Online: <span style='color: #4CAF50;'>%1</span>").arg(members.size());
players_online_label->setText(label_text);
if (chat_room_widget->isEnabled()) {
chat_room_widget->SetPlayerList(members);
AnnounceMultiplayerRoom::GameInfo local_game_info;
std::string my_nick = room_member->GetNickname();
for (const auto& m : members) {
if (m.nickname == my_nick) {
local_game_info = m.game_info;
break;
}
}
} else {
ClearUI();
room_member.reset();
// Ensure we don't think we are emulating if the status is "Not playing a game"
bool is_emulating = !local_game_info.name.empty() &&
local_game_info.name != tr("Not playing a game").toStdString();
int point_size = UISettings::IsGamescope() ? 11 : 10;
if (this->width() < 340) point_size = 10;
QFont font = players_online_label->font();
font.setPointSize(point_size);
players_online_label->setFont(font);
QString label_text;
if (!is_emulating) {
players_online_label->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
label_text = tr("<b>Players In Room: <span style='color: #00FF00;'>%1</span></b>").arg(members.size());
} else {
players_online_label->setAlignment(Qt::AlignCenter);
int g = 0, d = 0, o = 0;
for (const auto& m : members) {
bool m_playing = !m.game_info.name.empty() &&
m.game_info.name != tr("Not playing a game").toStdString();
if (m_playing && m.game_info.name == local_game_info.name) {
if (m.game_info.version == local_game_info.version) g++; else d++;
} else {
o++;
}
}
QStringList parts;
if (g > 0) parts << tr("<b>In-Game: <span style='color: #00FF00;'>%1</span></b>").arg(g);
if (d > 0) parts << tr("<b>Different Update: <span style='color: #FFD700;'>%1</span></b>").arg(d);
if (o > 0) parts << tr("<b>Other: <span style='color: #E0E0E0;'>%1</span></b>").arg(o);
QString sep = QStringLiteral("&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;•&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;");
if (this->width() < 400) sep = QStringLiteral("&nbsp;&nbsp;•&nbsp;&nbsp;");
label_text = parts.join(sep);
}
players_online_label->setText(label_text);
if (chat_room_widget->isEnabled()) chat_room_widget->SetPlayerList(members);
}
}
void MultiplayerRoomOverlay::UpdatePosition() {
if (!main_window) return;
if (!has_been_moved) {
QPoint main_window_pos = main_window->mapToGlobal(QPoint(0, 0));
move(main_window_pos.x() + main_window->width() - this->width() - 10, main_window_pos.y() + 10);
QPoint win_pos = main_window->mapToGlobal(QPoint(0, 0));
move(win_pos.x() + main_window->width() - width() - 15, win_pos.y() + 15);
}
}
void MultiplayerRoomOverlay::UpdateTheme() {
if (UISettings::IsDarkTheme()) {
// Dark Theme Colors
background_color = QColor(20, 20, 20, 180); // 180 alpha for transparency
border_color = QColor(60, 60, 60, 120);
players_online_label->setStyleSheet(QStringLiteral("color: #E0E0E0;"));
background_color = QColor(25, 25, 25, 225);
border_color = QColor(255, 255, 255, 40);
players_online_label->setStyleSheet(QStringLiteral("color: #FFFFFF;"));
} else {
// Light Theme Colors
background_color = QColor(245, 245, 245, 200); // 200 alpha for transparency
border_color = QColor(200, 200, 200, 120);
players_online_label->setStyleSheet(QStringLiteral("color: #141414;"));
background_color = QColor(245, 245, 245, 235);
border_color = QColor(0, 0, 0, 50);
players_online_label->setStyleSheet(QStringLiteral("color: #111111;"));
}
// The chat widget is a separate custom widget, so we need to tell it to update its theme too.
if (chat_room_widget) {
chat_room_widget->UpdateTheme();
}
update(); // Force a repaint of the overlay itself
if (chat_room_widget) chat_room_widget->UpdateTheme();
update();
}