diff --git a/src/citron/multiplayer/chat_room.cpp b/src/citron/multiplayer/chat_room.cpp index 8fea9f445..cfa0c0c41 100644 --- a/src/citron/multiplayer/chat_room.cpp +++ b/src/citron/multiplayer/chat_room.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -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->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_uniquesetSpacing(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_uniquechat_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(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(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 default_colors = { + "#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", + "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"}; + static constexpr std::array 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("%1
%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(); +} diff --git a/src/citron/multiplayer/chat_room.h b/src/citron/multiplayer/chat_room.h index 2b34db41c..0c1e05eab 100644 --- a/src/citron/multiplayer/chat_room.h +++ b/src/citron/multiplayer/chat_room.h @@ -4,28 +4,33 @@ #pragma once -#include // time tracking -#include // storing timestamps +#include #include #include #include -#include -#include +#include + +#include +#include +#include #include -#include +#include +#include +#include + #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; std::unordered_set block_list; std::unordered_map icon_cache; std::unordered_map color_overrides; + + // Highlight tracking with smooth fade-in/out + struct HighlightState { + float opacity = 0.0f; + QPointer animation; + QTimer* linger_timer = nullptr; + }; + std::unordered_map 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 sent_message_timestamps; static constexpr size_t MAX_MESSAGES_PER_INTERVAL = 3; static constexpr std::chrono::seconds THROTTLE_INTERVAL{5}; diff --git a/src/citron/multiplayer/chat_room.ui b/src/citron/multiplayer/chat_room.ui index f2b31b5da..ad55ace33 100644 --- a/src/citron/multiplayer/chat_room.ui +++ b/src/citron/multiplayer/chat_room.ui @@ -37,14 +37,7 @@ - Send Chat Message - - - - - - - Send Message + Type Here... diff --git a/src/citron/multiplayer/client_room.h b/src/citron/multiplayer/client_room.h index 8fee509de..955d4aa1e 100644 --- a/src/citron/multiplayer/client_room.h +++ b/src/citron/multiplayer/client_room.h @@ -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 +#include #include "citron/multiplayer/chat_room.h" namespace Ui { diff --git a/src/citron/util/multiplayer_room_overlay.cpp b/src/citron/util/multiplayer_room_overlay.cpp index c7699aaec..8953f078d 100644 --- a/src/citron/util/multiplayer_room_overlay.cpp +++ b/src/citron/util/multiplayer_room_overlay.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include #include #include #include @@ -23,24 +24,25 @@ MultiplayerRoomOverlay::MultiplayerRoomOverlay(QWidget* parent) main_window = qobject_cast(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: %1").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("Players In Room: %1").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("In-Game: %1").arg(g); + if (d > 0) parts << tr("Different Update: %1").arg(d); + if (o > 0) parts << tr("Other: %1").arg(o); + + QString sep = QStringLiteral("      โ€ข      "); + if (this->width() < 400) sep = QStringLiteral("  โ€ข  "); + + 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(); }