Compare commits

..

3 commits

Author SHA1 Message Date
BruebachL
d99798111e
[UserList] Unify friends/online/ignored list with section dividers and add search bar. (#7119)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
* [UserList] Unify friends/online/ignored list with section dividers and add search bar.

Took 31 minutes

Took 7 seconds

* [UserList] Add a light mode theme

Took 12 minutes

Took 11 seconds


Took 5 minutes

Took 2 minutes

* [UserList] Re-sort when a user's online state changes

setUserOnline() flipped the online flag but never re-sorted, so a buddy
who went offline kept the position they had while online and stayed at
the top of the list. Re-sort (and re-apply the filter) whenever the flag
actually changes, mirroring processUserInfo().

Took 10 minutes

* [UserList] Show users in every section they belong to

The sectioned list used one row per user with a priority rule
(ignored > buddy > online), so an online buddy only appeared under
"Buddies" and never in the "Online" list. Sections are now pure
membership views: a user gets one row per section they belong to, so an
online buddy appears under both "Online" and "Buddies".

- Track rows per (section, user) in sectionUsers instead of reparenting
  a single row; the name->primary-row map is kept for external lookups.
- Rebuild, presence and buddy/ignore mutations create/drop rows per
  section instead of moving a single row between sections.
- Dropping one membership no longer removes the user from the other
  sections.

* [UserList] Keyboard navigation for section dividers, popup on selection

Section dividers were not selectable, so arrow-key navigation skipped
them entirely, and the user popup only appeared on hover or click. Now:

- Dividers are selectable, so Up/Down navigation lands on them; they act
  as collapsible headers once focused (Enter/Space toggle, Left/Right
  collapse/expand per tree convention), with a focus indicator drawn by
  the existing delegate.
- The popup follows keyboard selection via currentItemChanged, exactly
  like mouse hover, and closes when the selection moves to a divider or
  leaves the list.
- The popup anchors on the hovered/selected row instead of a user-name
  lookup, so with duplicate rows (online + buddy) it stays attached to
  the row under the mouse/cursor.
- Left-arrow now actually collapses an expanded section divider: the
  collapse branch hardcoded the target expansion state to 'expanded',
  making the key a no-op.
- The user popup no longer flashes through a fade when hopping between
  users (hover or arrow-key navigation): a content swap keeps it opaque,
  and pending show/hide timers are cancelled so an armed hover timer
  cannot override a keyboard-selected row or a pending hide kill the
  newly shown popup.
- Bulk rebuild defers per-row divider-count updates to endBulkLoad(),
  removing the quadratic recount during large online-list loads.
- handleOnlineChangeLeft/handleListRemove skip the sort+filter+repaint
  when nothing actually changed.

* [UserList] Tune the role row gradient colors (dark parity, light mode)

Dark mode is byte-for-byte the pre-branch painter profile, with the
original saturated-left to navy-right fade restored verbatim. Light mode
uses the same language at high tint strength: role rows get colored
fades (0.75/0.65 left to 0.18/0.10 right), and regular users get flat
warm paper cards (AlternateBase) instead of the grey slate.

* [UserList] Deselect the list and close the popup on outside clicks

Clicking anywhere outside the tree, the popup or an open menu now clears
the selection and hides the popup, so a pinned popup does not stay open
when the list loses focus.

- The application-wide event filter watches every mouse press and treats
  a press as inside the list UI only when its target is the tree, the
  popup or an open menu (parent-chain walk), so a click on another list,
  a tab or the window background deselects.
- A hover popup now also closes when the cursor leaves the hovered row.
  The hide timer previously checked whether the cursor was over the
  tree, which is always true over empty list space and section dividers,
  so the popup stayed open after moving off the user.
- Deselection keeps the current item so keyboard navigation is not
  disturbed, and the pinned flag is dropped before hiding so the
  selection-changed handler does not hide twice.

Took 15 minutes

* [UserList] Use an enum for the list sections

The section identifiers were stringly-typed: eleven hardcoded
QStringLiteral comparisons scattered through user_list_widget.cpp, and
the display path (sectionTitle) maps every id through tr() anyway, so
the raw strings were never shown. A typo compiled fine and silently
broke a section.

- enum class Section { Buddy, Online, Ignore } replaces the section
  strings across the sectioned-list API (setSectioned, getSectionIds,
  setSectionExpanded, the sectionExpanded signal and all membership
  helpers), giving compile-time checks at every call site.
- sectionTitle becomes a switch over the enum and the dead raw-string
  fallback is gone.
- The expanded-section state persists the same stable keys via the
  panel widget boundary, so existing settings files survive unchanged.
- The divider reverse lookup in handleSectionExpansion no longer relies
  on an empty-string sentinel from QMap::key; it scans the three
  dividers and bails when the item is not one of them.

Took 12 minutes

# Commit time for manual adjustment:
# Took 2 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-15 22:53:13 +02:00
BruebachL
16b6132701
[Tabs] Add a setting to define startup tab on application launch (#7121)
* [Tabs] Add a setting to define startup tab on application launch.

Took 29 minutes

* Naming and sizing

Took 4 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-15 22:16:37 +02:00
BruebachL
fbe5c4ade0
[VDE] Add a new setting to determine initial tab (Context/Deck/Database) (#7122)
* [VDE] Add a new setting to determine initial tab (Context/Deck/Database)

Took 16 minutes

Took 4 seconds

* Adjust tooltip

Took 4 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-15 21:55:39 +02:00
36 changed files with 2305 additions and 431 deletions

View file

@ -260,6 +260,7 @@ set(cockatrice_SOURCES
src/interface/widgets/server/user/user_info_connection.cpp
src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_panel_widget.cpp
src/interface/widgets/server/user/user_list_widget.cpp
src/interface/widgets/settings_page/abstract_settings_page.cpp
src/interface/widgets/settings_page/appearance_settings_page.cpp
@ -396,6 +397,8 @@ set(cockatrice_SOURCES
src/interface/intents/intent_join_server_room.h
src/interface/intents/intent_login.cpp
src/interface/intents/intent_login.h
src/interface/intents/intent_open_server_room_by_name.cpp
src/interface/intents/intent_open_server_room_by_name.h
src/interface/intents/url_parser.cpp
src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp

View file

@ -0,0 +1,183 @@
#include "intent_open_server_room_by_name.h"
#include "../widgets/tabs/tab_room.h"
#include "../widgets/tabs/tab_supervisor.h"
#include "intent_connect_to_server.h"
#include <libcockatrice/protocol/pb/event_list_rooms.pb.h>
#include <libcockatrice/protocol/pb/response_join_room.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
IntentOpenServerRoomByName::IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
std::unique_ptr<ContextJoinRoom> _context,
const QString &_roomName)
: Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release()),
roomName(_roomName)
{
checkTimer.setInterval(250);
connect(&checkTimer, &QTimer::timeout, this, [this]() {
if (selectOpenRoom()) {
checkTimer.stop();
}
});
}
bool IntentOpenServerRoomByName::checkPrecondition() const
{
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false;
}
// peerPort() reflects the actual TCP peer, which may differ from the
// configured server port (e.g. when connecting through a proxy), so only
// the hostname is compared here.
if (remoteClient->peerName() != context->serverContext.hostname) {
return false;
}
if (QString::number(remoteClient->peerPort()) != context->serverContext.port) {
return false;
}
return true;
}
void IntentOpenServerRoomByName::onPreconditionSatisfied()
{
if (listening) {
return;
}
listening = true;
if (selectOpenRoom()) {
return;
}
// The room selector is the component that requests the room list, so the
// server tab must exist for the room to be resolved by name.
if (!tabSupervisor->getTabServer()) {
tabSupervisor->openTabServer();
}
if (!tabSupervisor->getTabServer()) {
emitFailed(tr("No server tab available"));
return;
}
connect(remoteClient, &RemoteClient::listRoomsEventReceived, this, &IntentOpenServerRoomByName::processListRooms);
connect(remoteClient, &RemoteClient::statusChanged, this, &IntentOpenServerRoomByName::onClientStatusChanged);
// The room tab may be opened by our own join, by the room selector's auto-join, or by a
// join that was already in flight. Poll until it shows up.
checkTimer.start();
// While no join has been sent yet, keep the room list fresh: the list may have been
// requested before we subscribed to it, or a response may have been dropped during a
// busy login burst. A stale list would otherwise leave the room unresolved forever.
connect(&refreshTimer, &QTimer::timeout, this, [this]() {
if (!joinPending) {
remoteClient->sendCommand(remoteClient->prepareSessionCommand(Command_ListRooms()));
}
});
refreshTimer.setInterval(5000);
refreshTimer.start();
// Last-resort failure for "the room genuinely is not in a fresh list". This must NOT
// fire while a join is in flight: a loaded server may take longer than that to answer
// during a login burst, and killing the intent early would leave the connection
// registered in the room with no tab to display it and every later join attempt
// would then be rejected with RespContextError.
QTimer::singleShot(20000, this, [this]() {
if (!joinPending) {
emitFailed(tr("Timed out while looking for the server room %1").arg(roomName));
}
});
}
void IntentOpenServerRoomByName::onPreconditionNotSatisfied()
{
runDependency(new IntentConnectToServer(remoteClient, &context->serverContext));
}
void IntentOpenServerRoomByName::onClientStatusChanged(ClientStatus status)
{
if (status != ClientStatus::StatusLoggedIn) {
emitFailed(tr("Disconnected while looking for the server room %1").arg(roomName));
}
}
bool IntentOpenServerRoomByName::selectOpenRoom()
{
const auto &roomTabs = tabSupervisor->getRoomTabs();
for (auto i = roomTabs.cbegin(), end = roomTabs.cend(); i != end; ++i) {
TabRoom *room = i.value();
if (room->getRoomName() == roomName) {
tabSupervisor->setCurrentWidget(room);
emitFinished();
return true;
}
}
return false;
}
void IntentOpenServerRoomByName::processListRooms(const Event_ListRooms &event)
{
if (selectOpenRoom()) {
return;
}
for (int i = 0; i < event.room_list_size(); ++i) {
const ServerInfo_Room &room = event.room_list(i);
if (room.has_name() && QString::fromStdString(room.name()) == roomName) {
openRoom(room);
return;
}
}
}
void IntentOpenServerRoomByName::openRoom(const ServerInfo_Room &roomInfo)
{
if (joinPending) {
return;
}
joinPending = true;
// Rooms flagged auto_join are joined by the room selector automatically. Sending our own
// Command_JoinRoom on top of that would be answered with RespContextError.
if (roomInfo.has_auto_join() && roomInfo.auto_join()) {
return;
}
Command_JoinRoom cmd;
cmd.set_room_id(roomInfo.room_id());
PendingCommand *pend = remoteClient->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this](const Response &r, const CommandContainer &, const QVariant &) { handleJoinResponse(r); });
remoteClient->sendCommand(pend);
}
void IntentOpenServerRoomByName::handleJoinResponse(const Response &response)
{
switch (response.response_code()) {
case Response::RespOk: {
const Response_JoinRoom &resp = response.GetExtension(Response_JoinRoom::ext);
if (!tabSupervisor->getRoomTabs().contains(resp.room_info().room_id())) {
tabSupervisor->addRoomTab(resp.room_info(), true);
}
emitFinished();
return;
}
case Response::RespNameNotFound:
emitFailed(tr("Failed to join the server room %1: it doesn't exist on the server.").arg(roomName));
return;
case Response::RespUserLevelTooLow:
emitFailed(tr("You do not have the required permission to join the server room %1.").arg(roomName));
return;
case Response::RespContextError:
// The room was already joined by someone else (e.g. the room selector's
// auto-join). It will show up in the room tabs shortly, so keep waiting.
return;
default:
emitFailed(tr("Failed to join the server room %1 due to an unknown error.").arg(roomName));
return;
}
}

View file

@ -0,0 +1,61 @@
#ifndef COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H
#define COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H
#include "contexts/context_join_room.h"
#include "intent.h"
#include "remote_client.h"
#include <QScopedPointer>
#include <QString>
#include <QTimer>
#include <memory>
class TabRoom;
class TabSupervisor;
class Event_ListRooms;
class ServerInfo_Room;
/**
* @brief Connects to the configured server and opens a room identified by its name.
*
* Room ids are assigned by the server per session, so the room is resolved by name from the
* room list once the client is logged in. If the room is already open it is simply selected.
*
* The join itself is sent directly through the client instead of `TabServer::joinRoom`, so a
* failed join only fails the intent silently instead of popping a modal error box during
* startup. Success is routed to `TabSupervisor::addRoomTab`, the same tab-creation machinery
* the normal join flow uses.
*/
class IntentOpenServerRoomByName : public Intent
{
Q_OBJECT
public:
IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
std::unique_ptr<ContextJoinRoom> _context,
const QString &_roomName);
protected:
bool checkPrecondition() const override;
void onPreconditionSatisfied() override;
void onPreconditionNotSatisfied() override;
private:
void processListRooms(const Event_ListRooms &event);
void openRoom(const ServerInfo_Room &roomInfo);
void handleJoinResponse(const Response &response);
void onClientStatusChanged(ClientStatus status);
bool selectOpenRoom();
TabSupervisor *tabSupervisor;
RemoteClient *remoteClient;
QScopedPointer<ContextJoinRoom> context;
QString roomName;
bool listening = false;
bool joinPending = false;
QTimer checkTimer;
QTimer refreshTimer;
};
#endif // COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H

View file

@ -123,7 +123,7 @@ void ThemeManager::ensureThemeDirectoryExists()
}
}
bool ThemeManager::isDarkMode(const QString &themeDirPath)
bool ThemeManager::isDarkMode(const QString &themeDirPath) const
{
ThemeConfig themeConfig = ThemeConfig::fromThemeDir(themeDirPath);
if (themeConfig.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) {

View file

@ -66,7 +66,14 @@ protected:
public:
bool isBuiltInTheme();
bool isDarkMode(const QString &themeDirPath);
// Explicit color scheme of the theme: theme.cfg's ColorScheme setting
// (Dark/Light), falling back to the OS color scheme when it is "System".
bool isDarkMode(const QString &themeDirPath) const;
// The resolved scheme of the currently active theme.
bool isDarkModeActive() const
{
return isDarkMode(currentThemePath);
}
QStringMap &getAvailableThemes();
// Returns the path to the currently active theme directory (empty = default)
QString getCurrentThemePath() const

View file

@ -1,6 +1,7 @@
#include "user_info_popup.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_manager.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
#include "user_list_painter.h"
@ -22,6 +23,42 @@
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
/// Qt stylesheets accept #aarrggbb, which is QColor::name(QColor::HexArgb).
static QString colorStr(const QColor &color)
{
return color.name(QColor::HexArgb);
}
PopupTheme PopupTheme::fromPalette(const QPalette &palette, bool dark)
{
PopupTheme t;
t.dark = dark;
const QColor window = palette.color(QPalette::Window);
const QColor base = palette.color(QPalette::Base);
const QColor mid = palette.color(QPalette::Mid);
const QColor text = palette.color(QPalette::Text);
const QColor disabledText = palette.color(QPalette::Disabled, QPalette::Text);
const QColor highlight = palette.color(QPalette::Highlight);
t.bg = window;
t.border = mid;
t.text = text;
t.subText = disabledText;
t.statusText = disabledText;
t.buttonBg = base;
t.buttonBorder = mid;
t.buttonHover = UserListPainter::blend(base, highlight, dark ? 0.30 : 0.12);
t.buttonPressed = UserListPainter::blend(base, highlight, dark ? 0.50 : 0.25);
t.buttonDisabled = disabledText;
t.closeBg = UserListPainter::blend(base, window, 0.5);
t.closeHover = dark ? QColor(200, 50, 50) : UserListPainter::blend(QColor(200, 50, 50), base, 0.45);
t.gamesRow = base;
t.gamesSelected = UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30);
t.gamesSeparator = mid;
t.gamesSeparator.setAlpha(90);
return t;
}
// ── Compact game row delegate ─────────────────────────────────────────────────
class PopupGameDelegate : public QStyledItemDelegate
@ -48,8 +85,14 @@ public:
const QRect rect = option.rect;
const ServerInfo_Game game = var.value<ServerInfo_Game>();
const bool selected = option.state & QStyle::State_Selected;
const bool dark = themeManager && themeManager->isDarkModeActive();
// The widget palette can be stale after a runtime theme change, so the
// rows are styled from the application palette (always current).
const QPalette pal = qApp->palette();
const QColor base = pal.color(QPalette::Base);
const QColor highlight = pal.color(QPalette::Highlight);
p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26));
p->fillRect(rect, selected ? UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30) : base);
// State colour dot
const QColor dot = game.started() ? QColor(239, 68, 68)
@ -64,7 +107,7 @@ public:
QFont tf = option.font;
tf.setBold(true);
p->setFont(tf);
p->setPen(QColor(205, 215, 230));
p->setPen(pal.color(QPalette::Text));
const int textX = rect.left() + 26;
const int countW = 52;
const int titleW = rect.width() - textX - countW - 6;
@ -74,13 +117,15 @@ public:
// Player count
const bool full = game.player_count() >= game.max_players();
p->setFont(option.font);
p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150));
p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text));
p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()),
Qt::AlignVCenter | Qt::AlignRight,
QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players()));
// Row separator
p->setPen(QColor(24, 32, 44));
QColor separator = pal.color(QPalette::Mid);
separator.setAlpha(90);
p->setPen(separator);
p->drawLine(rect.bottomLeft(), rect.bottomRight());
p->restore();
@ -95,17 +140,17 @@ UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent)
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user,
bool online,
const QPixmap &avatar,
const QPixmap &cardArt,
const CardArtParams &params)
void UserInfoHeaderWidget::setUserData(const ServerInfo_User &_user,
bool _online,
const QPixmap &_avatar,
const QPixmap &_cardArt,
const CardArtParams &_params)
{
m_user = user;
m_online = online;
m_avatar = avatar;
m_cardArt = cardArt;
m_params = params;
user = _user;
online = _online;
avatar = _avatar;
cardArt = _cardArt;
params = _params;
update();
}
@ -115,29 +160,37 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = this->rect();
const UserLevelFlags level(m_user.user_level());
const QString userName = QString::fromStdString(m_user.name());
const QString privLevel = QString::fromStdString(m_user.privlevel());
const UserLevelFlags level(user.user_level());
const QString userName = QString::fromStdString(user.name());
const QString privLevel = QString::fromStdString(user.privlevel());
// Dark base
p.fillRect(rect, QColor(14, 18, 26));
const bool dark = themeManager && themeManager->isDarkModeActive();
const UserListPainter::Style style = UserListPainter::resolveStyle(qApp->palette(), dark);
// Palette surface
{
QLinearGradient bg(0, 0, rect.width(), 0);
bg.setColorAt(0, style.cardStart);
bg.setColorAt(1, style.cardEnd);
p.fillRect(rect, bg);
}
// ── Card art background ───────────────────────────────────────────────────
if (!m_cardArt.isNull()) {
if (!cardArt.isNull()) {
const int w = rect.width();
const int h = rect.height();
const int mL = qRound(w * m_params.marginPctL);
const int mR = qRound(w * m_params.marginPctR);
const int mL = qRound(w * params.marginPctL);
const int mR = qRound(w * params.marginPctR);
const int dW = w - mL - mR;
const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height());
const double scale = base * m_params.zoom;
const int sW = qRound(m_cardArt.width() * scale);
const int sH = qRound(m_cardArt.height() * scale);
const double base = qMax(double(dW) / cardArt.width(), double(h) / cardArt.height());
const double scale = base * params.zoom;
const int sW = qRound(cardArt.width() * scale);
const int sH = qRound(cardArt.height() * scale);
const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const QPixmap scaled = cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const int srcX = (sW - dW) / 2;
const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h));
const int srcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h));
QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
@ -155,12 +208,14 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
p.setOpacity(1.0);
}
// Bottom gradient overlay so avatar and text are always legible
// Bottom gradient overlay so avatar and text are always legible. The scrim
// is the palette's Window color so it reads naturally in either scheme.
{
const QColor scrim = qApp->palette().color(QPalette::Window);
QLinearGradient ov(0, 0, 0, rect.height());
ov.setColorAt(0.0, QColor(14, 18, 26, 0));
ov.setColorAt(0.55, QColor(14, 18, 26, 110));
ov.setColorAt(1.0, QColor(14, 18, 26, 230));
ov.setColorAt(0.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 0));
ov.setColorAt(0.55, QColor(scrim.red(), scrim.green(), scrim.blue(), 110));
ov.setColorAt(1.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 230));
p.fillRect(rect, ov);
}
@ -187,20 +242,20 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
p.save();
p.setClipPath(clip);
if (!m_avatar.isNull()) {
p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
if (!avatar.isNull()) {
p.drawPixmap(ar, avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
} else {
p.setPen(Qt::NoPen);
p.setBrush(accent.darker(200));
p.setBrush(UserListPainter::blend(accent, style.base, dark ? 0.45 : 0.72));
p.drawEllipse(ar);
const QPixmap pawn =
UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel);
UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, user.pawn_colors(), false, privLevel);
p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn);
}
p.restore();
// Status ring
p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5));
p.setPen(QPen(online ? QColor(34, 197, 94) : style.ringOffline, 2.5));
p.setBrush(Qt::NoBrush);
p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25));
@ -212,7 +267,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
nf.setBold(true);
nf.setPointSizeF(nf.pointSizeF() * 1.12);
p.setFont(nf);
p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115));
p.setPen(online ? style.textOnline : style.textOffline);
p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft,
QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw));
@ -243,143 +298,173 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
const int bw = bfm.horizontalAdvance(badge.text) + 10;
const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15);
p.setPen(Qt::NoPen);
p.setBrush(badge.color.darker(160));
p.setBrush(UserListPainter::blend(badge.color, style.base, dark ? 0.55 : 0.78));
p.drawRoundedRect(br, 3, 3);
p.setPen(badge.color.lighter(150));
p.setPen(dark ? UserListPainter::blend(badge.color, Qt::white, 0.5)
: UserListPainter::blend(badge.color, Qt::black, 0.35));
p.drawText(br, Qt::AlignCenter, badge.text);
}
}
// ── UserInfoPopup ─────────────────────────────────────────────────────────────
UserInfoPopup::UserInfoPopup(TabSupervisor *ts,
AbstractClient *client,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap,
UserInfoPopup::UserInfoPopup(TabSupervisor *_ts,
AbstractClient *_client,
const QMap<QString, QPixmap> *_avatarCache,
const QMap<QString, QPixmap> *_cardArtCache,
const QMap<QString, CardArtParams> *_cardArtParamsMap,
QWidget *parent)
: QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache),
m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap)
: QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), ts(_ts), client(_client), avatarCache(_avatarCache),
cardArtCache(_cardArtCache), cardArtParamsMap(_cardArtParamsMap)
{
setAttribute(Qt::WA_ShowWithoutActivating);
setFixedWidth(PopupWidth);
setFrameShape(QFrame::NoFrame);
buildUi();
// Restyle the popup chrome when the theme or its color scheme changes.
if (themeManager) {
connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme);
}
}
void UserInfoPopup::buildUi()
{
setStyleSheet(QStringLiteral("UserInfoPopup {"
" background:#0e1218;"
" border:1px solid #1e2838;"
" border-radius:8px;"
"}"));
auto *root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// Header
m_header = new UserInfoHeaderWidget(this);
root->addWidget(m_header);
header = new UserInfoHeaderWidget(this);
root->addWidget(header);
// Action area — rebuilt per user
m_actionArea = new QWidget(this);
m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;"));
root->addWidget(m_actionArea);
actionArea = new QWidget(this);
root->addWidget(actionArea);
// Thin separator
auto *sep = new QFrame(this);
sep->setFrameShape(QFrame::HLine);
sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;"));
root->addWidget(sep);
separator = new QFrame(this);
separator->setFrameShape(QFrame::HLine);
root->addWidget(separator);
// Games header row
auto *gh = new QHBoxLayout;
gh->setContentsMargins(10, 4, 8, 2);
auto *gl = new QLabel(tr("Games"), this);
gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;"));
gh->addWidget(gl);
gamesLabel = new QLabel(tr("Games"), this);
gh->addWidget(gamesLabel);
gh->addStretch();
m_refreshBtn = new QPushButton(QStringLiteral(""), this);
m_refreshBtn->setFixedSize(20, 20);
m_refreshBtn->setFlat(true);
m_refreshBtn->setStyleSheet(
QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}"
"QPushButton:hover{color:white;}"));
connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(m_refreshBtn);
refreshBtn = new QPushButton(QStringLiteral(""), this);
refreshBtn->setFixedSize(20, 20);
refreshBtn->setFlat(true);
connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(refreshBtn);
root->addLayout(gh);
// Status label
m_gamesStatus = new QLabel(this);
m_gamesStatus->setAlignment(Qt::AlignCenter);
m_gamesStatus->setStyleSheet(
QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;"));
root->addWidget(m_gamesStatus);
gamesStatus = new QLabel(this);
gamesStatus->setAlignment(Qt::AlignCenter);
root->addWidget(gamesStatus);
// Games list
m_gamesModel = new QStandardItemModel(this);
m_gamesView = new QListView(this);
m_gamesView->setModel(m_gamesModel);
m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView));
m_gamesView->setFrameShape(QFrame::NoFrame);
m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_gamesView->setMaximumHeight(220);
m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}"
"QListView::item:selected{background:#232e42;}"));
m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu);
gamesModel = new QStandardItemModel(this);
gamesView = new QListView(this);
gamesView->setModel(gamesModel);
gamesView->setItemDelegate(new PopupGameDelegate(gamesView));
gamesView->setFrameShape(QFrame::NoFrame);
gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
gamesView->setMaximumHeight(220);
gamesView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu);
root->addWidget(m_gamesView);
root->addWidget(gamesView);
// Close button — positioned absolutely in the top-right corner
m_closeBtn = new QPushButton(QStringLiteral(""), this);
m_closeBtn->setFixedSize(22, 22);
m_closeBtn->setFlat(true);
m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;"
"border:none;border-radius:11px;font-size:10px;}"
"QPushButton:hover{color:white;background:rgba(200,50,50,200);}"));
connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested);
closeBtn = new QPushButton(QStringLiteral(""), this);
closeBtn->setFixedSize(22, 22);
closeBtn->setFlat(true);
connect(closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested);
applyTheme();
}
void UserInfoPopup::applyTheme()
{
const bool dark = themeManager && themeManager->isDarkModeActive();
theme = PopupTheme::fromPalette(qApp->palette(), dark);
setStyleSheet(QStringLiteral("UserInfoPopup {"
" background:%1;"
" border:1px solid %2;"
" border-radius:8px;"
"}")
.arg(colorStr(theme.bg), colorStr(theme.border)));
actionArea->setStyleSheet(QStringLiteral("background:%1;").arg(colorStr(theme.bg)));
separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border)));
gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;")
.arg(colorStr(theme.subText)));
refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}"
"QPushButton:hover{color:%2;}")
.arg(colorStr(theme.subText), colorStr(theme.text)));
gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;")
.arg(colorStr(theme.statusText)));
gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}"
"QListView::item:selected{background:%2;}")
.arg(colorStr(theme.gamesRow), colorStr(theme.gamesSelected)));
closeBtn->setStyleSheet(
QStringLiteral("QPushButton{background:%1;color:%2;"
"border:none;border-radius:11px;font-size:10px;}"
"QPushButton:hover{color:%3;background:%4;}")
.arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover)));
header->update();
}
// ── Action button factory ─────────────────────────────────────────────────────
static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p)
static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p, const PopupTheme &t)
{
auto *b = new QPushButton(label, p);
b->setToolTip(tip);
b->setFixedHeight(26);
b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
b->setStyleSheet(QStringLiteral("QPushButton{"
" background:#192030;color:#b8c8de;border:1px solid #263040;"
" background:%1;color:%2;border:1px solid %3;"
" border-radius:4px;font-size:11px;padding:0 4px;"
"}"
"QPushButton:hover{background:#223050;color:white;}"
"QPushButton:pressed{background:#162030;}"
"QPushButton:disabled{color:#384858;border-color:#192030;}"));
"QPushButton:hover{background:%4;color:%5;}"
"QPushButton:pressed{background:%6;}"
"QPushButton:disabled{color:%7;border-color:%3;}")
.arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover),
colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled)));
return b;
}
void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{
// Clear previous contents
delete m_actionArea->layout();
const auto old = m_actionArea->findChildren<QPushButton *>(QString{}, Qt::FindDirectChildrenOnly);
delete actionArea->layout();
const auto old = actionArea->findChildren<QPushButton *>(QString{}, Qt::FindDirectChildrenOnly);
for (auto *w : old) {
w->deleteLater();
}
const QString name = QString::fromStdString(userInfo.name());
const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level());
const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name()));
const auto ownLevel = UserLevelFlags(ts->getUserInfo()->user_level());
const bool isSelf = (name == QString::fromStdString(ts->getUserInfo()->name()));
const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator);
const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin);
const auto their = UserLevelFlags(userInfo.user_level());
const bool isReg = their.testFlag(ServerInfo_User::IsRegistered);
auto *grid = new QGridLayout(m_actionArea);
auto *grid = new QGridLayout(actionArea);
grid->setContentsMargins(8, 6, 8, 6);
grid->setSpacing(4);
@ -394,16 +479,16 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
};
// ── Always visible ────────────────────────────────────────────────────────
auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea);
auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), actionArea, theme);
chat->setEnabled(!isSelf && online);
connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); });
add(chat);
auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea);
auto *prof = makeBtn(tr("Profile"), tr("View user profile"), actionArea, theme);
connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); });
add(prof);
auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea);
auto *games = makeBtn(tr("Games"), tr("Show this user's games"), actionArea, theme);
games->setEnabled(!isSelf && online);
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
add(games);
@ -411,20 +496,20 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
// ── Buddy / ignore (registered users only) ────────────────────────────────
if (!isSelf && isReg) {
if (isBuddy) {
auto *b = makeBtn(tr(" Buddy"), tr("Remove from buddy list"), m_actionArea);
auto *b = makeBtn(tr(" Buddy"), tr("Remove from buddy list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); });
add(b);
} else {
auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea);
auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); });
add(b);
}
if (isIgnored) {
auto *b = makeBtn(tr(" Ignore"), tr("Remove from ignore list"), m_actionArea);
auto *b = makeBtn(tr(" Ignore"), tr("Remove from ignore list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); });
add(b);
} else {
auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea);
auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); });
add(b);
}
@ -437,10 +522,10 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
col = 0;
} // start mod section on a fresh row
auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea);
auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea);
auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea);
auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea);
auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), actionArea, theme);
auto *warn = makeBtn(tr("Warn"), tr("Warn user"), actionArea, theme);
auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), actionArea, theme);
auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), actionArea, theme);
connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); });
connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); });
connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); });
@ -453,31 +538,31 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
// ── Admin actions ─────────────────────────────────────────────────────────
if (!isSelf && isAdmin) {
auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea);
auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), actionArea, theme);
connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); });
add(notes);
if (their.testFlag(ServerInfo_User::IsModerator)) {
auto *b = makeBtn(tr(" Mod"), tr("Demote from moderator"), m_actionArea);
auto *b = makeBtn(tr(" Mod"), tr("Demote from moderator"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); });
add(b);
} else if (isReg) {
auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea);
auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); });
add(b);
}
if (their.testFlag(ServerInfo_User::IsJudge)) {
auto *b = makeBtn(tr(" Judge"), tr("Demote from judge"), m_actionArea);
auto *b = makeBtn(tr(" Judge"), tr("Demote from judge"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); });
add(b);
} else if (isReg) {
auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea);
auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); });
add(b);
}
}
m_actionArea->adjustSize();
actionArea->adjustSize();
}
void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
@ -488,7 +573,7 @@ void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool on
void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
{
const QModelIndex idx = m_gamesView->indexAt(pos);
const QModelIndex idx = gamesView->indexAt(pos);
if (!idx.isValid()) {
return;
}
@ -501,8 +586,9 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
QMenu menu(this);
menu.setStyleSheet(
QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}"
"QMenu::item:selected{background:#223050;}"));
QStringLiteral("QMenu{background:%1;color:%2;border:1px solid %3;border-radius:4px;}"
"QMenu::item:selected{background:%4;}")
.arg(colorStr(theme.bg), colorStr(theme.text), colorStr(theme.border), colorStr(theme.buttonHover)));
const bool canJoin = !game.started() && game.player_count() < game.max_players();
QAction *join = menu.addAction(tr("Join game"));
@ -513,7 +599,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
spec = menu.addAction(tr("Spectate"));
}
const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos));
const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos));
if (!chosen) {
return;
}
@ -529,17 +615,17 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
void UserInfoPopup::refreshHeader()
{
if (m_currentUser.isEmpty()) {
if (currentUser.isEmpty()) {
return;
}
const QPixmap avatar = m_avatarCache ? m_avatarCache->value(m_currentUser) : QPixmap{};
const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(m_currentUser))
? m_cardArtParamsMap->value(m_currentUser)
const QPixmap avatar = avatarCache ? avatarCache->value(currentUser) : QPixmap{};
const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(currentUser))
? cardArtParamsMap->value(currentUser)
: CardArtParams{};
const QString artKey = m_currentUser + u'|' + params.cardName + u'|' + params.cardProviderId;
const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{};
m_header->setUserData(m_currentUserInfo, m_currentOnline, avatar, cardArt, params);
const QString artKey = currentUser + u'|' + params.cardName + u'|' + params.cardProviderId;
const QPixmap cardArt = (cardArtCache && !params.cardName.isEmpty()) ? cardArtCache->value(artKey) : QPixmap{};
header->setUserData(currentUserInfo, currentOnline, avatar, cardArt, params);
}
void UserInfoPopup::showForUser(const QString &userName,
@ -548,9 +634,9 @@ void UserInfoPopup::showForUser(const QString &userName,
bool isBuddy,
bool isIgnored)
{
m_currentUser = userName;
m_currentUserInfo = userInfo;
m_currentOnline = online;
currentUser = userName;
currentUserInfo = userInfo;
currentOnline = online;
// Header
refreshHeader();
@ -559,14 +645,14 @@ void UserInfoPopup::showForUser(const QString &userName,
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
// Games list reset
m_gamesModel->clear();
m_gamesView->hide();
m_gamesStatus->setText(tr("Loading games…"));
m_gamesStatus->show();
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
// Close button — top-right corner, above everything
m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6);
m_closeBtn->raise();
closeBtn->move(PopupWidth - closeBtn->width() - 6, 6);
closeBtn->raise();
adjustSize();
fetchGames();
@ -576,40 +662,40 @@ void UserInfoPopup::showForUser(const QString &userName,
void UserInfoPopup::fetchGames()
{
if (!m_client || m_currentUser.isEmpty()) {
if (!client || currentUser.isEmpty()) {
return;
}
Command_GetGamesOfUser cmd;
cmd.set_user_name(m_currentUser.toStdString());
cmd.set_user_name(currentUser.toStdString());
const QString snapshot = m_currentUser;
PendingCommand *pend = m_client->prepareSessionCommand(cmd);
const QString snapshot = currentUser;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, snapshot](const Response &r) { onGamesReceived(r, snapshot); });
m_client->sendCommand(pend);
client->sendCommand(pend);
}
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
{
if (forUser != m_currentUser) {
if (forUser != currentUser) {
return; // stale response — different user showing now
}
m_gamesModel->clear();
gamesModel->clear();
if (r.response_code() != Response::RespOk) {
m_gamesStatus->setText(tr("Could not load games."));
m_gamesStatus->show();
m_gamesView->hide();
gamesStatus->setText(tr("Could not load games."));
gamesStatus->show();
gamesView->hide();
return;
}
const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext);
if (resp.game_list_size() == 0) {
m_gamesStatus->setText(tr("No active games."));
m_gamesStatus->show();
m_gamesView->hide();
gamesStatus->setText(tr("No active games."));
gamesStatus->show();
gamesView->hide();
return;
}
@ -617,29 +703,29 @@ void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
auto *item = new QStandardItem;
item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData);
item->setEditable(false);
m_gamesModel->appendRow(item);
gamesModel->appendRow(item);
}
m_gamesStatus->hide();
m_gamesView->show();
gamesStatus->hide();
gamesView->show();
// Fit exactly to the number of visible rows, scroll when more than 5
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = m_gamesModel->rowCount();
const int count = gamesModel->rowCount();
const int visible = qMin(count, maxRows);
m_gamesView->setFixedHeight(visible * rowH + 2);
m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
gamesView->setFixedHeight(visible * rowH + 2);
gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
adjustSize();
}
void UserInfoPopup::refreshGames()
{
m_gamesModel->clear();
m_gamesView->hide();
m_gamesStatus->setText(tr("Loading games…"));
m_gamesStatus->show();
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
fetchGames();
}

View file

@ -26,6 +26,35 @@ namespace PopupRoles
constexpr int GameData = Qt::UserRole + 10;
}
// Popup theme
/**
* Palette-derived colors for the popup chrome. Both color schemes read from
* the active QPalette so custom palettes are respected. @c dark only tunes the
* blend strengths.
*/
struct PopupTheme
{
bool dark = false;
QColor bg;
QColor border;
QColor text;
QColor subText;
QColor buttonBg;
QColor buttonBorder;
QColor buttonHover;
QColor buttonPressed;
QColor buttonDisabled;
QColor closeBg;
QColor closeHover;
QColor gamesRow;
QColor gamesSelected;
QColor gamesSeparator;
QColor statusText;
static PopupTheme fromPalette(const QPalette &palette, bool dark);
};
// ── Header widget ─────────────────────────────────────────────────────────────
/**
@ -51,21 +80,21 @@ class UserInfoHeaderWidget : public QWidget
public:
explicit UserInfoHeaderWidget(QWidget *parent = nullptr);
void setUserData(const ServerInfo_User &user,
bool online,
const QPixmap &avatar,
const QPixmap &cardArt,
const CardArtParams &params);
void setUserData(const ServerInfo_User &_user,
bool _online,
const QPixmap &_avatar,
const QPixmap &_cardArt,
const CardArtParams &_params);
protected:
void paintEvent(QPaintEvent *e) override;
private:
ServerInfo_User m_user;
bool m_online = false;
QPixmap m_avatar;
QPixmap m_cardArt;
CardArtParams m_params;
ServerInfo_User user;
bool online = false;
QPixmap avatar;
QPixmap cardArt;
CardArtParams params;
};
// ── Main popup ────────────────────────────────────────────────────────────────
@ -93,11 +122,11 @@ class UserInfoPopup : public QFrame
static constexpr int PopupWidth = 316;
public:
explicit UserInfoPopup(TabSupervisor *tabSupervisor,
AbstractClient *client,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap,
explicit UserInfoPopup(TabSupervisor *_ts,
AbstractClient *_client,
const QMap<QString, QPixmap> *_avatarCache,
const QMap<QString, QPixmap> *_cardArtCache,
const QMap<QString, CardArtParams> *_cardArtParamsMap,
QWidget *parent);
/**
@ -108,9 +137,9 @@ public:
showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
void fetchGames();
[[nodiscard]] QString currentUser() const
[[nodiscard]] QString getCurrentUser() const
{
return m_currentUser;
return currentUser;
}
/** Called when buddy/ignore status changes externally while popup is open. */
@ -156,25 +185,30 @@ private slots:
private:
void buildUi();
void applyTheme();
void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
TabSupervisor *m_ts;
AbstractClient *m_client;
const QMap<QString, QPixmap> *m_avatarCache;
const QMap<QString, QPixmap> *m_cardArtCache;
const QMap<QString, CardArtParams> *m_cardArtParamsMap;
TabSupervisor *ts;
AbstractClient *client;
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
QString m_currentUser;
ServerInfo_User m_currentUserInfo;
bool m_currentOnline = false;
PopupTheme theme;
UserInfoHeaderWidget *m_header;
QWidget *m_actionArea; ///< rebuilt per user
QListView *m_gamesView;
QStandardItemModel *m_gamesModel;
QLabel *m_gamesStatus;
QPushButton *m_closeBtn;
QPushButton *m_refreshBtn;
QString currentUser;
ServerInfo_User currentUserInfo;
bool currentOnline = false;
UserInfoHeaderWidget *header;
QWidget *actionArea; ///< rebuilt per user
QLabel *gamesLabel;
QFrame *separator;
QListView *gamesView;
QStandardItemModel *gamesModel;
QLabel *gamesStatus;
QPushButton *closeBtn;
QPushButton *refreshBtn;
};
#endif // COCKATRICE_USER_INFO_POPUP_H

View file

@ -3,6 +3,7 @@
#include "../../interface/pixel_map_generator.h"
#include <QAbstractScrollArea>
#include <QApplication>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
@ -19,6 +20,29 @@ QSize UserListPainter::sizeHint()
return QSize(0, RowHeight);
}
UserListPainter::Style UserListPainter::resolveStyle(const QPalette &palette, bool dark)
{
Style style;
style.dark = dark;
const QColor base = palette.color(QPalette::Base);
const QColor alt = palette.color(QPalette::AlternateBase);
style.cardStart = base;
style.cardEnd = (alt != base) ? alt : palette.color(QPalette::Midlight);
style.base = base;
style.textOnline = palette.color(QPalette::Text);
style.textOffline = palette.color(QPalette::Disabled, QPalette::Text);
style.ringOffline = palette.color(QPalette::Disabled, QPalette::Text);
style.dropShadow = dark;
return style;
}
QColor UserListPainter::blend(const QColor &a, const QColor &b, qreal t)
{
const qreal u = 1.0 - t;
return QColor(qRound(a.red() * u + b.red() * t), qRound(a.green() * u + b.green() * t),
qRound(a.blue() * u + b.blue() * t), qRound(a.alpha() * u + b.alpha() * t));
}
QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online)
{
QColor accentColor;
@ -59,18 +83,41 @@ int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRec
void UserListPainter::drawBackground(QPainter *painter,
const QRectF &cardRect,
const QColor &accentColor,
bool selected)
bool selected,
const Style &style,
bool hasRole)
{
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320));
bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30));
if (style.dark) {
// Dark mode darkens the role color to fit the dark surface and fades
// it into the deep navy surface on the right. The text drop shadow
// keeps the username legible over the colored edge.
bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320));
bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30));
} else if (hasRole) {
// Light mode pegs the role color on the left at near full strength
// and fades it into the white surface on the right. The tint stays
// bright enough that the dark text remains legible without a shadow.
bg.setColorAt(0, blend(style.cardStart, accentColor, selected ? 0.75 : 0.65));
bg.setColorAt(1, blend(style.cardEnd, accentColor, selected ? 0.18 : 0.10));
} else {
// Regular users are the light theme's neutral paper cards. A flat
// warm card fill (the normal row surface, slightly deepened) keeps
// every row clearly visible without borrowing a role color. Selection
// shifts the fill toward a soft slate so the highlight still reads.
const QColor paper = style.cardEnd.darker(108);
bg.setColorAt(0, blend(paper, accentColor, selected ? 0.35 : 0.0));
bg.setColorAt(1, blend(paper, accentColor, selected ? 0.25 : 0.0));
}
painter->setPen(Qt::NoPen);
painter->setBrush(bg);
painter->drawRoundedRect(cardRect, 6, 6);
painter->setBrush(accentColor);
painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
if (style.dark || hasRole || selected) {
painter->setBrush(accentColor);
painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
}
}
static QString makeKey(const QString &user, const QString &card, const QString &providerId)
@ -163,7 +210,8 @@ void UserListPainter::drawAvatar(QPainter *painter,
const UserLevelFlags &userLevel,
const ServerInfo_User &userInfo,
const QString &privLevel,
const QMap<QString, QPixmap> *avatarCache)
const QMap<QString, QPixmap> *avatarCache,
const Style &style)
{
QPainterPath clipPath;
clipPath.addEllipse(avatarRect);
@ -183,7 +231,7 @@ void UserListPainter::drawAvatar(QPainter *painter,
}
if (!drewAvatar) {
painter->setBrush(accentColor.darker(200));
painter->setBrush(blend(accentColor, style.base, style.dark ? 0.45 : 0.72));
painter->setPen(Qt::NoPen);
painter->drawEllipse(avatarRect);
@ -196,9 +244,9 @@ void UserListPainter::drawAvatar(QPainter *painter,
painter->restore();
}
void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online)
void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style)
{
const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95);
const QColor statusColor = online ? QColor(34, 197, 94) : style.ringOffline;
painter->setPen(QPen(statusColor, 2));
painter->setBrush(Qt::NoBrush);
@ -212,7 +260,7 @@ void UserListPainter::drawUserName(QPainter *painter,
int textX,
const QString &userName,
bool online,
bool selected)
const Style &style)
{
QFont nameFont = option.font;
nameFont.setBold(true);
@ -221,10 +269,12 @@ void UserListPainter::drawUserName(QPainter *painter,
const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20);
const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10);
painter->setPen(QColor(0, 0, 0, 200));
painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName);
if (style.dropShadow) {
painter->setPen(QColor(0, 0, 0, 200));
painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName);
}
painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115));
painter->setPen(online ? style.textOnline : style.textOffline);
painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName);
}
@ -262,7 +312,8 @@ void UserListPainter::drawBadges(QPainter *painter,
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online)
bool online,
const Style &style)
{
if (badges.isEmpty()) {
return;
@ -284,15 +335,17 @@ void UserListPainter::drawBadges(QPainter *painter,
int bx = cardRight - 6 - totalBadgeW;
for (const Badge &b : badges) {
const QColor col = online ? b.color : b.color.darker(180);
const QColor col = online ? b.color : blend(b.color, style.base, 0.55);
const QColor surface = blend(col, style.base, style.dark ? 0.55 : 0.78);
const QColor text = style.dark ? blend(col, Qt::white, 0.5) : blend(col, Qt::black, 0.35);
const int bw = fm.horizontalAdvance(b.text) + 8;
const QRect br(bx, rect.top() + 44, bw, 13);
painter->setPen(Qt::NoPen);
painter->setBrush(col.darker(online ? 160 : 220));
painter->setBrush(surface);
painter->drawRoundedRect(br, 3, 3);
painter->setPen(col.lighter(online ? 160 : 100));
painter->setPen(text);
painter->drawText(br, Qt::AlignCenter, b.text);
bx += bw + 4;
@ -305,11 +358,19 @@ void UserListPainter::paint(QPainter *painter,
const ServerInfo_User &userInfo,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap)
const QMap<QString, CardArtParams> *cardArtParamsMap,
bool dark)
{
painter->save();
painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
// The delegate supplies the application palette in option.palette, which
// always reflects the active theme. The widget palette can be stale after
// a runtime theme change, so it is only used as a defensive fallback.
const QPalette pal =
option.palette == QPalette() ? (option.widget ? option.widget->palette() : qApp->palette()) : option.palette;
const Style style = resolveStyle(pal, dark);
const QRect rect = option.rect;
const bool online = index.data(Qt::UserRole + 1).toBool();
const bool selected = option.state & QStyle::State_Selected;
@ -317,6 +378,9 @@ void UserListPainter::paint(QPainter *painter,
const QString userName = QString::fromStdString(userInfo.name());
const QString privLevel = QString::fromStdString(userInfo.privlevel());
const QColor accentColor = getAccentColor(userLevel, online);
const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) ||
userLevel.testFlag(ServerInfo_User::IsModerator) ||
userLevel.testFlag(ServerInfo_User::IsJudge);
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
const int cardRight = getCardRight(option, rect);
@ -324,19 +388,19 @@ void UserListPainter::paint(QPainter *painter,
? cardArtParamsMap->value(userName)
: CardArtParams{};
drawBackground(painter, cardRect, accentColor, selected);
drawBackground(painter, cardRect, accentColor, selected, style, hasRole);
drawCardArt(painter, rect, cardRight, userName, cardArtCache, params);
const QRect avatarRect = getAvatarRect(rect);
drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache);
drawStatusRing(painter, avatarRect, online);
drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache, style);
drawStatusRing(painter, avatarRect, online, style);
const int textX = avatarRect.right() + TextSpacing;
drawUserName(painter, option, rect, cardRight, textX, userName, online, selected);
drawUserName(painter, option, rect, cardRight, textX, userName, online, style);
drawCountryFlag(painter, rect, textX, userInfo);
const QList<Badge> badges = buildBadges(userLevel, privLevel);
drawBadges(painter, option, rect, cardRight, badges, online);
drawBadges(painter, option, rect, cardRight, badges, online, style);
painter->restore();
}

View file

@ -6,6 +6,7 @@
#include <QColor>
#include <QList>
#include <QMap>
#include <QPalette>
#include <QPixmap>
#include <QRect>
#include <QSize>
@ -28,13 +29,36 @@ struct CardArtParams
class UserListPainter
{
public:
/**
* Palette-derived surface colors for the current color scheme. Both the
* light and the dark scheme read from the active QPalette so custom
* palettes are respected. @c dark only tunes the blend strengths (and
* whether the name text keeps its drop shadow).
*/
struct Style
{
bool dark = true;
QColor cardStart; ///< row fill, left edge (normal)
QColor cardEnd; ///< row fill, right edge (normal)
QColor base; ///< lightest surface, used for blending accent hues
QColor textOnline;
QColor textOffline;
QColor ringOffline;
bool dropShadow = false;
};
static Style resolveStyle(const QPalette &palette, bool dark);
/// Linear interpolation: @p t = 0 returns @p a, @p t = 1 returns @p b.
static QColor blend(const QColor &a, const QColor &b, qreal t);
static void paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index,
const ServerInfo_User &userInfo,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
const QMap<QString, CardArtParams> *cardArtParamsMap,
bool dark);
static QSize sizeHint();
@ -55,7 +79,12 @@ private:
static QColor getAccentColor(const UserLevelFlags &userLevel, bool online);
static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect);
static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected);
static void drawBackground(QPainter *painter,
const QRectF &cardRect,
const QColor &accentColor,
bool selected,
const Style &style,
bool hasRole);
static QRect getAvatarRect(const QRect &rect);
static void drawAvatar(QPainter *painter,
const QRect &avatarRect,
@ -64,8 +93,9 @@ private:
const UserLevelFlags &userLevel,
const ServerInfo_User &userInfo,
const QString &privLevel,
const QMap<QString, QPixmap> *avatarCache);
static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online);
const QMap<QString, QPixmap> *avatarCache,
const Style &style);
static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style);
static void drawUserName(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
@ -73,7 +103,7 @@ private:
int textX,
const QString &userName,
bool online,
bool selected);
const Style &style);
static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo);
static QList<Badge> buildBadges(const UserLevelFlags &userLevel, const QString &privLevel);
static void drawBadges(QPainter *painter,
@ -81,7 +111,8 @@ private:
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online);
bool online,
const Style &style);
};
#endif // COCKATRICE_USER_LIST_PAINTER_H
#endif // COCKATRICE_USER_LIST_PAINTER_H

View file

@ -0,0 +1,88 @@
#include "user_list_panel_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "user_list_manager.h"
#include "user_list_widget.h"
#include <QLineEdit>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/settings/interface_settings.h>
namespace
{
// The persisted section keys are the serialization contract with the user's
// settings file, so the values must stay stable across versions.
QString sectionKey(UserListWidget::Section section)
{
switch (section) {
case UserListWidget::Section::Buddy:
return QStringLiteral("buddy");
case UserListWidget::Section::Online:
return QStringLiteral("online");
case UserListWidget::Section::Ignore:
return QStringLiteral("ignore");
}
return {};
}
} // namespace
UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent)
: QWidget(parent)
{
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(2);
searchBar = new QLineEdit(this);
searchBar->setClearButtonEnabled(true);
mainLayout->addWidget(searchBar);
userList = new UserListWidget(_tabSupervisor, _client, UserListWidget::RoomList, this);
userList->setSectioned(
{UserListWidget::Section::Buddy, UserListWidget::Section::Online, UserListWidget::Section::Ignore});
mainLayout->addWidget(userList, 1);
connect(searchBar, &QLineEdit::textChanged, userList, &UserListWidget::setFilterText);
connect(userList, &UserListWidget::sectionExpanded, this, &UserListPanelWidget::persistExpandedSections);
connect(userList, &UserListWidget::openMessageDialog, this, &UserListPanelWidget::openMessageDialog);
// Restore the persisted expansion state, then apply it to the tree.
const QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections();
for (const UserListWidget::Section section : userList->getSectionIds()) {
userList->setSectionExpanded(section, expandedSections.contains(sectionKey(section)));
}
retranslateUi();
}
void UserListPanelWidget::bind(UserListManager *manager)
{
userList->bind(manager);
}
void UserListPanelWidget::persistExpandedSections(UserListWidget::Section section, bool expanded)
{
const QString key = sectionKey(section);
QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections();
if (expanded) {
if (!expandedSections.contains(key)) {
expandedSections.append(key);
}
} else {
expandedSections.removeAll(key);
}
SettingsCache::instance().userInterface().setUserListExpandedSections(expandedSections);
}
void UserListPanelWidget::retranslateUi()
{
searchBar->setPlaceholderText(tr("Search users..."));
userList->retranslateUi();
}
UserListWidget *UserListPanelWidget::getUserList() const
{
return userList;
}

View file

@ -0,0 +1,43 @@
/**
* @file user_list_panel_widget.h
* @ingroup Lobby
*/
#ifndef COCKATRICE_USER_LIST_PANEL_WIDGET_H
#define COCKATRICE_USER_LIST_PANEL_WIDGET_H
#include "user_list_widget.h"
#include <QWidget>
class AbstractClient;
class QLineEdit;
class TabSupervisor;
class UserListManager;
/**
* A unified user list: a search bar above a single tree whose section headers
* (buddy, online, ignored) are inline dividers. The tree owns the scrolling.
*/
class UserListPanelWidget : public QWidget
{
Q_OBJECT
public:
explicit UserListPanelWidget(TabSupervisor *tabSupervisor, AbstractClient *client, QWidget *parent = nullptr);
void bind(UserListManager *manager);
void retranslateUi();
[[nodiscard]] UserListWidget *getUserList() const;
signals:
void openMessageDialog(const QString &userName, bool focus);
private:
void persistExpandedSections(UserListWidget::Section section, bool expanded);
QLineEdit *searchBar = nullptr;
UserListWidget *userList = nullptr;
};
#endif // COCKATRICE_USER_LIST_PANEL_WIDGET_H

View file

@ -18,6 +18,7 @@
#include <QDialog>
#include <QGroupBox>
#include <QQueue>
#include <QSet>
#include <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
@ -103,12 +104,13 @@ public:
class UserListItemDelegate : public QStyledItemDelegate
{
QTreeWidget *tree;
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
public:
explicit UserListItemDelegate(QObject *const parent,
explicit UserListItemDelegate(QTreeWidget *tree,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
@ -147,6 +149,12 @@ public:
BuddyList,
IgnoreList
};
enum class Section
{
Buddy,
Online,
Ignore
};
private:
UserListManager *manager = nullptr;
@ -154,30 +162,69 @@ private:
UserCardArtProvider *cardArtProvider = nullptr;
QMap<QString, CardArtParams> cardArtParamsMap;
// ── Hover popup ───────────────────────────────────────────────────────────
UserInfoPopup *m_userInfoPopup = nullptr;
QTimer *m_showPopupTimer = nullptr;
QTimer *m_hidePopupTimer = nullptr;
QString m_hoveredUser;
bool m_popupPinned = false;
bool m_bulkLoading = false;
UserInfoPopup *userInfoPopup = nullptr;
QTimer *showPopupTimer = nullptr;
QTimer *hidePopupTimer = nullptr;
QString hoveredUser;
bool popupPinned = false;
bool bulkLoading = false;
void showPopupForUser(const QString &userName);
/**
* Popup functions are anchored on the row, not the user name. In sectioned
* mode a user can own several rows (online + buddy), and the popup must
* follow the hovered/selected row rather than a lookup by name.
*/
void showPopupForUser(UserListTWI *item);
void hidePopup(bool immediate = false);
void positionPopup(const QString &userName);
void positionPopup(UserListTWI *item);
void connectPopupSignals();
/** True when @p widget is the tree, the popup or an open menu. */
bool isPressInsideListUi(const QWidget *widget) const;
void clearSelectionAndClosePopup();
bool isItemNearViewport(const UserListTWI *item) const;
void requestAvatarsForVisibleItems();
// Sectioned mode (single tree with inline dividers)
bool sectioned = false;
QList<Section> sectionIds;
QMap<Section, QTreeWidgetItem *> sectionItems;
// One row per (section, user): a user that is online AND a buddy appears in
// both the "Online" and the "Buddies" sections, so the same user can own
// several rows, each hanging off its section's divider.
QMap<Section, QMap<QString, UserListTWI *>> sectionUsers;
QSet<Section> expandedSections;
void createSectionItems();
QTreeWidgetItem *createSectionItem(Section section);
[[nodiscard]] QString sectionTitle(Section section) const;
void updateSectionDivider(Section section);
void handleSectionExpansion(QTreeWidgetItem *item, bool expanded);
void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded);
void handleOnlineChange(const ServerInfo_User &user);
void handleOnlineChangeLeft(const QString &userName);
void handleListAdd(Section section, const ServerInfo_User &user);
void handleListRemove(Section section, const QString &userName);
/** Creates or updates the row for @p user in @p section. */
UserListTWI *ensureSectionMembership(Section section, const ServerInfo_User &user, bool online);
/** Removes and deletes the row for @p userName in @p section. */
bool dropSectionMembership(Section section, const QString &userName);
/** Sorts, refilters and repaints after a sectioned mode mutation. */
void finishSectionedMutation();
void updateCardArtParams(const ServerInfo_User &user, const QString &userName);
void processUserInfo(Section section, const ServerInfo_User &user, bool online);
QMap<QString, UserListTWI *> users;
TabSupervisor *tabSupervisor;
AbstractClient *client;
UserListType type;
QTreeWidget *userTree;
QTreeWidget *userTree = nullptr;
UserListItemDelegate *itemDelegate;
UserContextMenu *userContextMenu;
int onlineCount;
QString titleStr;
QString filterText;
bool showTitle = true;
void updateCount();
void applyFilter();
void refreshPopupButtons(const QString &userName);
private slots:
void userClicked(QTreeWidgetItem *item, int column);
@ -189,12 +236,14 @@ signals:
void addIgnore(const QString &userName);
void removeIgnore(const QString &userName);
void joinGameRequested(int gameId, int roomId, bool asSpectator);
void sectionExpanded(Section section, bool expanded);
public:
UserListWidget(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
UserListType _type,
QWidget *parent = nullptr);
~UserListWidget() override;
void bind(UserListManager *mgr);
void applyDisplayMode();
void beginBulkLoad();
@ -205,6 +254,14 @@ public:
void processUserInfo(const ServerInfo_User &user, bool online);
bool deleteUser(const QString &userName);
void setUserOnline(const QString &userName, bool online);
void setFilterText(const QString &text);
void setShowTitle(bool showTitle);
void setSectioned(const QList<Section> &ids);
void setSectionExpanded(Section section, bool expanded);
[[nodiscard]] const QList<Section> &getSectionIds() const
{
return sectionIds;
}
[[nodiscard]] const QMap<QString, UserListTWI *> &getUsers() const
{
return users;

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../main.h"
#include "../server/user/user_info_connection.h"
#include "update/client/release_channel.h"
#include <QCoreApplication>
@ -11,6 +12,7 @@
#include <QTranslator>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
#include <libcockatrice/settings/updates_settings.h>
#include <libcockatrice/utility/macros.h>
@ -121,8 +123,61 @@ GeneralSettingsPage::GeneralSettingsPage()
connect(&showTipsOnStartup, &QCheckBox::clicked, &settings.personal(), &PersonalSettings::setShowTipsOnStartup);
// startup destination
for (int i = 0; i < 8; ++i) {
startupTabSelector.addItem(""); // texts set in retranslateUi
}
startupTabSelector.setCurrentIndex(settings.tabs().getStartupTabIndex());
connect(&startupTabSelector, qOverload<int>(&QComboBox::currentIndexChanged), &settings.tabs(),
&TabsSettings::setStartupTabIndex);
connect(&startupTabSelector, qOverload<int>(&QComboBox::currentIndexChanged), this,
&GeneralSettingsPage::updateStartupServerControlsVisibility);
const QString savedHost = settings.tabs().getStartupServerHost();
const QString savedPort = settings.tabs().getStartupServerPort();
int startupServerIndex = -1;
UserConnection_Information uci;
for (const auto &savedServer : uci.getServerInfo()) {
const UserConnection_Information &info = savedServer.second;
const QString saveName = info.getSaveName();
if (saveName.isEmpty()) {
continue;
}
startupServerSelector.addItem(saveName, QVariantList{info.getServer(), info.getPort()});
if (startupServerIndex == -1 && info.getServer() == savedHost && info.getPort() == savedPort) {
startupServerIndex = startupServerSelector.count() - 1;
}
}
startupServerSelector.setCurrentIndex(startupServerIndex);
connect(&startupServerSelector, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
const QVariantList serverInfo = startupServerSelector.itemData(index).toList();
if (serverInfo.size() != 2) {
return;
}
TabsSettings &tabs = SettingsCache::instance().tabs();
tabs.setStartupServerHost(serverInfo[0].toString());
tabs.setStartupServerPort(serverInfo[1].toString());
});
startupRoomNameEdit = new QLineEdit(settings.tabs().getStartupRoomName());
// Default (Expanding) would stretch the whole controls column when this row becomes visible,
// so size it like the combo boxes instead: fills the column, never widens it.
startupRoomNameEdit->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
connect(startupRoomNameEdit, &QLineEdit::editingFinished, this,
[this] { SettingsCache::instance().tabs().setStartupRoomName(startupRoomNameEdit->text().trimmed()); });
auto *startupGrid = new QGridLayout;
startupGrid->addWidget(&showTipsOnStartup, 0, 0, 1, 2);
startupGrid->addWidget(&startupTabLabel, 1, 0);
startupGrid->addWidget(&startupTabSelector, 1, 1);
startupGrid->addWidget(&startupServerLabel, 2, 0);
startupGrid->addWidget(&startupServerSelector, 2, 1);
startupGrid->addWidget(&startupRoomLabel, 3, 0);
startupGrid->addWidget(startupRoomNameEdit, 3, 1);
updateStartupServerControlsVisibility();
startupGroupBox = new QGroupBox;
startupGroupBox->setLayout(startupGrid);
@ -357,6 +412,17 @@ void GeneralSettingsPage::languageBoxChanged(int index)
SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString());
}
void GeneralSettingsPage::updateStartupServerControlsVisibility()
{
const int index = startupTabSelector.currentIndex();
const bool serverNeeded = index == StartupTab::StartupTabServer || index == StartupTab::StartupTabServerRoom;
const bool roomNeeded = index == StartupTab::StartupTabServerRoom;
startupServerLabel.setVisible(serverNeeded);
startupServerSelector.setVisible(serverNeeded);
startupRoomLabel.setVisible(roomNeeded);
startupRoomNameEdit->setVisible(roomNeeded);
}
void GeneralSettingsPage::retranslateUi()
{
languageGroupBox->setTitle(tr("Language settings"));
@ -393,6 +459,20 @@ void GeneralSettingsPage::retranslateUi()
updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client"));
newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice"));
showTipsOnStartup.setText(tr("Show tips on startup"));
startupTabLabel.setText(tr("Startup tab:"));
startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home"));
startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckStorage, tr("Visual Deck Storage"));
startupTabSelector.setItemText(StartupTab::StartupTabDeckStorage, tr("Deck Storage"));
startupTabSelector.setItemText(StartupTab::StartupTabReplays, tr("Game Replays"));
startupTabSelector.setItemText(StartupTab::StartupTabDeckEditor, tr("Deck Editor"));
startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckEditor, tr("Visual Deck Editor"));
startupTabSelector.setItemText(StartupTab::StartupTabServer, tr("Server"));
startupTabSelector.setItemText(StartupTab::StartupTabServerRoom, tr("Server Room"));
startupTabSelector.setToolTip(
tr("The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened."));
startupServerLabel.setText(tr("Server:"));
startupRoomLabel.setText(tr("Room:"));
startupRoomNameEdit->setPlaceholderText(tr("Room name"));
resetAllPathsButton->setText(tr("Reset all paths"));
const auto &settings = SettingsCache::instance();

View file

@ -30,6 +30,7 @@ private slots:
void tokenDatabasePathButtonClicked();
void resetAllPathsClicked();
void languageBoxChanged(int index);
void updateStartupServerControlsVisibility();
private:
QStringList findQmFiles();
@ -71,6 +72,12 @@ private:
QLabel updateReleaseChannelLabel;
QLabel advertiseTranslationPageLabel;
QCheckBox showTipsOnStartup;
QLabel startupTabLabel;
QComboBox startupTabSelector;
QLabel startupServerLabel;
QComboBox startupServerSelector;
QLabel startupRoomLabel;
QLineEdit *startupRoomNameEdit;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -183,6 +183,13 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
connect(&defaultDeckEditorTypeSelector, QOverload<int>::of(&QComboBox::currentIndexChanged),
&SettingsCache::instance().deckEditor(), &DeckEditorSettings::setDefaultDeckEditorType);
vdeStartupTabSelector.addItem(""); // these will be set in retranslateUI
vdeStartupTabSelector.addItem("");
vdeStartupTabSelector.addItem("");
vdeStartupTabSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getVdeStartupTab());
connect(&vdeStartupTabSelector, QOverload<int>::of(&QComboBox::currentIndexChanged),
&SettingsCache::instance().deckEditor(), &DeckEditorSettings::setVdeStartupTab);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setText("?");
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setAutoRaise(true);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(false);
@ -242,10 +249,12 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1);
deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0);
deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 5, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 5, 1);
deckEditorGrid->addWidget(labelWidget, 6, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 6, 1);
deckEditorGrid->addWidget(&vdeStartupTabLabel, 5, 0);
deckEditorGrid->addWidget(&vdeStartupTabSelector, 5, 1);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 6, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 6, 1);
deckEditorGrid->addWidget(labelWidget, 7, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 7, 1);
deckEditorGroupBox = new QGroupBox;
deckEditorGroupBox->setLayout(deckEditorGrid);
@ -368,6 +377,12 @@ void UserInterfaceSettingsPage::retranslateUi()
defaultDeckEditorTypeLabel.setText(tr("Default deck editor type"));
defaultDeckEditorTypeSelector.setItemText(TabSupervisor::ClassicDeckEditor, tr("Classic Deck Editor"));
defaultDeckEditorTypeSelector.setItemText(TabSupervisor::VisualDeckEditor, tr("Visual Deck Editor"));
vdeStartupTabLabel.setText(tr("Visual deck editor startup tab"));
vdeStartupTabSelector.setItemText(VdeStartupTabContext, tr("Context"));
vdeStartupTabSelector.setItemText(VdeStartupTabDeckDisplay, tr("Deck display"));
vdeStartupTabSelector.setItemText(VdeStartupTabDatabaseDisplay, tr("Database display"));
vdeStartupTabSelector.setToolTip(
tr("Context mode: New decks open on the database display, existing decks open on the deck view."));
commanderSpellbookIntegrationEnabledLabel.setText(
tr("CommanderSpellbook integration to estimate commander bracket"));

View file

@ -50,6 +50,8 @@ private:
QCheckBox visualDeckStorageSelectionAnimationCheckBox;
QLabel defaultDeckEditorTypeLabel;
QComboBox defaultDeckEditorTypeSelector;
QLabel vdeStartupTabLabel;
QComboBox vdeStartupTabSelector;
QLabel commanderSpellbookIntegrationEnabledLabel;
QComboBox commanderSpellbookIntegrationEnabledSelector;
QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel;

View file

@ -263,12 +263,12 @@ protected slots:
/** @brief Handles dock close events. */
void closeEvent(QCloseEvent *event) override;
private:
/** @brief Sets the deck for this tab.
* @param _deck The deck object.
*/
virtual void setDeck(const LoadedDeck &_deck);
private:
/** @brief Helper for editing decks from the clipboard. */
void editDeckInClipboard(bool annotated);

View file

@ -6,6 +6,7 @@
#include "../interface/widgets/server/chat_view/chat_view.h"
#include "../interface/widgets/server/game_selector.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_panel_widget.h"
#include "../interface/widgets/server/user/user_list_widget.h"
#include "../main.h"
#include "../utility/completer_utils.h"
@ -60,23 +61,10 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
tempMap.insert(info.room_id(), gameTypes);
gameSelector = new GameSelector(client, tabSupervisor, this, QMap<int, QString>(), tempMap, true, true);
auto *tabs = new QTabWidget(this);
friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList);
friendsList->bind(tabSupervisor->getUserListManager());
userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList);
userList->bind(tabSupervisor->getUserListManager());
ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList);
ignoreList->bind(tabSupervisor->getUserListManager());
connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this,
SIGNAL(openMessageDialog(const QString &, bool)));
connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this,
SIGNAL(openMessageDialog(const QString &, bool)));
tabs->addTab(friendsList, tr("Friends"));
tabs->addTab(userList, tr("Online"));
tabs->addTab(ignoreList, tr("Ignored"));
userListPanel = new UserListPanelWidget(tabSupervisor, client, this);
userListPanel->bind(tabSupervisor->getUserListManager());
userList = userListPanel->getUserList();
connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog);
chatView = new ChatView(tabSupervisor, nullptr, true, this);
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
@ -126,7 +114,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
auto *hbox = new QHBoxLayout;
hbox->addWidget(splitter, 3);
hbox->addWidget(tabs, 1);
hbox->addWidget(userListPanel, 1);
aLeaveRoom = new QAction(this);
connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest);
@ -181,7 +169,7 @@ void TabRoom::retranslateUi()
{
gameSelector->retranslateUi();
chatView->retranslateUi();
userList->retranslateUi();
userListPanel->retranslateUi();
sayLabel->setText(tr("&Say:"));
chatGroupBox->setTitle(tr("Chat"));
roomMenu->setTitle(tr("&Room"));

View file

@ -27,6 +27,7 @@ class Message;
} // namespace google
class AbstractClient;
class UserListWidget;
class UserListPanelWidget;
class QLabel;
class ChatView;
class QPushButton;
@ -57,9 +58,8 @@ private:
QMap<int, QString> gameTypes;
GameSelector *gameSelector;
UserListWidget *friendsList;
UserListPanelWidget *userListPanel;
UserListWidget *userList;
UserListWidget *ignoreList;
const UserListProxy *userListProxy;
ChatView *chatView;
QLabel *sayLabel;
@ -114,6 +114,10 @@ public:
{
return roomId;
}
[[nodiscard]] QString getRoomName() const
{
return roomName;
}
[[nodiscard]] const QMap<int, QString> &getGameTypes() const
{
return gameTypes;

View file

@ -335,7 +335,12 @@ static void checkAndTrigger(QAction *checkableAction, bool checked)
}
/**
* Opens the always-available tabs, depending on settings.
* Opens the always-available tabs, depending on settings, and lands on the configured startup tab.
*
* The startup destination is a request: tabs that were not open before (deck editors, storage
* tabs disabled in the Tabs menu) are opened as part of the startup flow. Destinations that
* require a server connection (Server, Server Room) are handled asynchronously by MainWindow
* through the intent system, since this class has no RemoteClient.
*/
void TabSupervisor::initStartupTabs()
{
@ -351,6 +356,42 @@ void TabSupervisor::initStartupTabs()
if (SettingsCache::instance().tabs().getTabReplaysOpen()) {
openTabReplays();
}
switch (SettingsCache::instance().tabs().getStartupTabIndex()) {
case StartupTab::StartupTabVisualDeckStorage:
if (!tabVisualDeckStorage) {
openTabVisualDeckStorage();
}
setCurrentWidget(tabVisualDeckStorage);
break;
case StartupTab::StartupTabDeckStorage:
if (!tabDeckStorage) {
openTabDeckStorage();
}
setCurrentWidget(tabDeckStorage);
break;
case StartupTab::StartupTabReplays:
if (!tabReplays) {
openTabReplays();
}
setCurrentWidget(tabReplays);
break;
case StartupTab::StartupTabDeckEditor:
addDeckEditorTab(LoadedDeck());
break;
case StartupTab::StartupTabVisualDeckEditor:
addVisualDeckEditorTab(LoadedDeck());
break;
case StartupTab::StartupTabServer:
case StartupTab::StartupTabServerRoom:
// Handled asynchronously by MainWindow::applyStartupDestination(); Home stays selected
// until the server connection succeeds.
break;
case StartupTab::StartupTabHome:
default:
setCurrentWidget(tabHome);
break;
}
}
/**

View file

@ -184,6 +184,7 @@ public slots:
void actTabVisualDeckStorage(bool checked);
void actTabReplays(bool checked);
void openTabServer();
void addRoomTab(const ServerInfo_Room &info, bool setCurrent);
private slots:
void refreshShortcuts();
@ -209,7 +210,6 @@ private slots:
void gameJoined(const Event_GameJoined &event);
void localGameJoined(const Event_GameJoined &event);
void gameLeft(TabGame *tab);
void addRoomTab(const ServerInfo_Room &info, bool setCurrent);
void roomLeft(TabRoom *tab);
TabMessage *addMessageTab(const QString &userName, bool focus);
void replayLeft(TabGame *tab);

View file

@ -30,6 +30,7 @@
#include <libcockatrice/models/deck_list/deck_list_model.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/layouts_settings.h>
/**
@ -98,6 +99,33 @@ void TabDeckEditorVisual::onDeckChanged()
tabContainer->sampleHandWidget->setDeckModel(deckStateManager->getModel());
}
/** @brief Sets the deck and selects the startup sub-tab matching the context. */
void TabDeckEditorVisual::setDeck(const LoadedDeck &_deck)
{
AbstractTabDeckEditor::setDeck(_deck);
int startupTab = SettingsCache::instance().deckEditor().getVdeStartupTab();
if (startupTab == VdeStartupTabContext) {
// New (empty) decks open on the database display so cards can be added
// right away. Existing decks open on the deck view.
startupTab = _deck.isEmpty() ? VdeStartupTabDatabaseDisplay : VdeStartupTabDeckDisplay;
}
switch (startupTab) {
case VdeStartupTabDatabaseDisplay:
tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDatabaseDisplay);
break;
case VdeStartupTabDeckDisplay:
tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDeckView);
break;
default:
qCWarning(TabSupervisorLog) << "Unknown VdeStartupTab [" << startupTab
<< "]; falling back to the deck view";
tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDeckView);
break;
}
}
/** @brief Creates menus for deck editing and view options, including dock actions. */
void TabDeckEditorVisual::createMenus()
{

View file

@ -164,6 +164,15 @@ public slots:
* @return true if successful, false otherwise.
*/
bool actSaveDeckAs() override;
private:
/**
* @brief Sets the deck for this tab and selects the sub-tab to open on
* startup, per the "Visual deck editor startup tab" setting (Context /
* Deck display / Database display).
* @param _deck The deck object.
*/
void setDeck(const LoadedDeck &_deck) override;
};
#endif

View file

@ -49,6 +49,17 @@ class TabDeckEditorVisualTabWidget : public QTabWidget
Q_OBJECT
public:
/**
* @brief Sub-tab order in the container; addNewTab() is called in this order.
*/
enum TabIndex
{
VisualDeckView,
VisualDatabaseDisplay,
DeckAnalytics,
SampleHand,
};
/**
* @brief Construct the tab widget with required models.
* @param parent Parent widget.

View file

@ -32,8 +32,14 @@
#include "../interface/widgets/dialogs/dlg_update.h"
#include "../interface/widgets/dialogs/dlg_view_log.h"
#include "../interface/widgets/tabs/tab_game.h"
#include "../interface/widgets/tabs/tab_server.h"
#include "../interface/widgets/tabs/tab_supervisor.h"
#include "../main.h"
#include "intents/contexts/context_connect_to_server.h"
#include "intents/contexts/context_join_room.h"
#include "intents/intent_connect_to_server.h"
#include "intents/intent_login.h"
#include "intents/intent_open_server_room_by_name.h"
#include "logger.h"
#include "version_string.h"
#include "widgets/dialogs/dlg_connect.h"
@ -77,6 +83,7 @@
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/servers_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
#include <libcockatrice/settings/updates_settings.h>
#define GITHUB_PAGES_URL "https://cockatrice.github.io"
@ -540,6 +547,7 @@ MainWindow::MainWindow(QWidget *parent)
// run startup check async
QTimer::singleShot(0, this, &MainWindow::startupConfigCheck);
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
}
void MainWindow::startupConfigCheck()
@ -648,6 +656,82 @@ void MainWindow::startupConfigCheck()
}
}
/**
* Drives the server-based startup destinations (Server lobby, Server Room) through the intent
* system: fetch saved credentials, connect to the configured server, then land on the Lobby or
* join the configured room by name.
*/
void MainWindow::applyStartupDestination()
{
// An explicit command-line connect takes precedence over the startup destination.
if (!connectTo.isEmpty()) {
return;
}
const int destination = SettingsCache::instance().tabs().getStartupTabIndex();
if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) {
return;
}
const QString host = SettingsCache::instance().tabs().getStartupServerHost();
const QString port = SettingsCache::instance().tabs().getStartupServerPort();
if (host.isEmpty() || port.isEmpty()) {
qCWarning(WindowMainStartupLog) << "Startup destination needs a configured server";
return;
}
auto serverContext = std::make_shared<ContextConnectToServer>();
serverContext->hostname = host;
serverContext->port = port;
auto *credentials = new IntentGetLoginCredentials(serverContext.get());
auto *connector = new IntentConnectToServer(getRemoteClient(), serverContext.get());
connect(credentials, &Intent::finished, connector, &Intent::execute);
connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed);
connect(connector, &Intent::finished, this,
[this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); });
connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed);
credentials->execute();
}
void MainWindow::onStartupDestinationConnected(int destination, const ContextConnectToServer &serverContext)
{
// The server tab must exist: it is what requests the room list.
if (!tabSupervisor->getTabServer()) {
tabSupervisor->openTabServer();
}
if (destination == StartupTab::StartupTabServerRoom) {
auto roomContext = std::make_unique<ContextJoinRoom>();
roomContext->serverContext = serverContext;
auto *roomIntent = new IntentOpenServerRoomByName(tabSupervisor, getRemoteClient(), std::move(roomContext),
SettingsCache::instance().tabs().getStartupRoomName());
roomIntent->setParent(this);
connect(roomIntent, &Intent::failed, this, &MainWindow::startupDestinationFailed);
roomIntent->execute();
return;
}
if (tabSupervisor->getTabServer()) {
tabSupervisor->setCurrentWidget(tabSupervisor->getTabServer());
} else {
qCWarning(WindowMainStartupLog) << "Startup destination: server tab could not be opened";
}
}
void MainWindow::startupDestinationFailed(const QString &reason)
{
qCWarning(WindowMainStartupLog) << "Startup destination failed:" << reason;
}
bool MainWindow::startupDestinationConnectsToServer() const
{
const int destination = SettingsCache::instance().tabs().getStartupTabIndex();
return destination == StartupTab::StartupTabServer || destination == StartupTab::StartupTabServerRoom;
}
void MainWindow::alertForcedOracleRun(const QString &version, bool isUpdate)
{
if (isUpdate) {
@ -750,7 +834,8 @@ void MainWindow::changeEvent(QEvent *event)
connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(),
connectTo.password());
} else if (SettingsCache::instance().servers().getAutoConnect() &&
!SettingsCache::instance().debug().getLocalGameOnStartup()) {
!SettingsCache::instance().debug().getLocalGameOnStartup() &&
!startupDestinationConnectsToServer()) {
qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect...";
DlgConnect dlg(this);
connectionController->connectToServerDirect(dlg.getHost(), static_cast<unsigned int>(dlg.getPort()),

View file

@ -55,6 +55,7 @@ class ServerInfo_User;
class TabSupervisor;
class WndSets;
class DlgTipOfTheDay;
struct ContextConnectToServer;
class MainWindow : public QMainWindow
{
@ -105,6 +106,11 @@ private slots:
void startupConfigCheck();
void alertForcedOracleRun(const QString &version, bool isUpdate);
void applyStartupDestination();
void onStartupDestinationConnected(int destination, const ContextConnectToServer &serverContext);
void startupDestinationFailed(const QString &reason);
[[nodiscard]] bool startupDestinationConnectsToServer() const;
private:
static const QString appName;
static const QStringList fileNameFilters;

View file

@ -2,6 +2,7 @@
#define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H
#include <QString>
#include <QStringList>
class IInterfaceSettingsProvider
{
@ -41,6 +42,7 @@ public:
[[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() const = 0;
[[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0;
[[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0;
[[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0;
};
#endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H

View file

@ -1,11 +1,17 @@
#ifndef COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H
#define COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H
#include <QString>
class ITabsSettingsProvider
{
public:
virtual ~ITabsSettingsProvider() = default;
[[nodiscard]] virtual int getStartupTabIndex() const = 0;
[[nodiscard]] virtual QString getStartupServerHost() const = 0;
[[nodiscard]] virtual QString getStartupServerPort() const = 0;
[[nodiscard]] virtual QString getStartupRoomName() const = 0;
[[nodiscard]] virtual bool getTabVisualDeckStorageOpen() const = 0;
[[nodiscard]] virtual bool getTabServerOpen() const = 0;
[[nodiscard]] virtual bool getTabAccountOpen() const = 0;

View file

@ -25,6 +25,11 @@ int DeckEditorSettings::getDefaultDeckEditorType() const
return getValue("defaultDeckEditorType", QString(), QString(), 1).toInt();
}
int DeckEditorSettings::getVdeStartupTab() const
{
return getValue("vdeStartupTab", QString(), QString(), VdeStartupTabContext).toInt();
}
void DeckEditorSettings::setOpenDeckInNewTab(bool _openDeckInNewTab)
{
setValue(_openDeckInNewTab, "openDeckInNewTab");
@ -47,6 +52,12 @@ void DeckEditorSettings::setDefaultDeckEditorType(int _defaultDeckEditorType)
setValue(_defaultDeckEditorType, "defaultDeckEditorType");
}
void DeckEditorSettings::setVdeStartupTab(int _vdeStartupTab)
{
setValue(_vdeStartupTab, "vdeStartupTab");
emit vdeStartupTabChanged(_vdeStartupTab);
}
int DeckEditorSettings::getCommanderSpellbookIntegrationEnabled() const
{
return getValue("commanderspellbookintegrationenabled", QString(), QString(),

View file

@ -13,6 +13,13 @@ enum commanderSpellbookIntegrationEnabledIndex
commanderSpellbookIntegrationEnabledIndexUnprompted,
};
enum VdeStartupTab
{
VdeStartupTabContext, ///< Match the opened deck: new decks show the database display, existing decks the deck view
VdeStartupTabDeckDisplay, ///< Always open the Visual Deck View
VdeStartupTabDatabaseDisplay, ///< Always open the Visual Database Display
};
class DeckEditorSettings : public SettingsManager, public IDeckEditorSettingsProvider
{
Q_OBJECT
@ -23,6 +30,7 @@ public:
[[nodiscard]] bool getBannerCardComboBoxVisible() const override;
[[nodiscard]] bool getTagsWidgetVisible() const override;
[[nodiscard]] int getDefaultDeckEditorType() const override;
[[nodiscard]] int getVdeStartupTab() const;
[[nodiscard]] int getCommanderSpellbookIntegrationEnabled() const;
[[nodiscard]] bool getCommanderSpellbookIntegrationUseOfficialBracketNames() const;
@ -30,6 +38,7 @@ public:
void setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible);
void setTagsWidgetVisible(bool _tagsWidgetVisible);
void setDefaultDeckEditorType(int _defaultDeckEditorType);
void setVdeStartupTab(int _vdeStartupTab);
void setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled);
void setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames);
@ -38,6 +47,7 @@ signals:
void tagsWidgetVisibleChanged(bool visible);
void commanderSpellbookIntegrationEnabledChanged(int enabled);
void commanderSpellbookIntegrationUseOfficialBracketNamesChanged(bool useOfficialBracketNames);
void vdeStartupTabChanged(int vdeStartupTab);
public:
explicit DeckEditorSettings(const QString &settingPath, QObject *parent = nullptr);

View file

@ -170,6 +170,12 @@ bool InterfaceSettings::getBattlefieldFlashEnabled() const
return getValue("battlefieldFlashEnabled", QString(), QString(), true).toBool();
}
QStringList InterfaceSettings::getUserListExpandedSections() const
{
return getValue("userListExpandedSections", QString(), QString(), QStringList({"buddy", "online", "ignore"}))
.toStringList();
}
void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus)
{
setValue(_useTearOffMenus, "useTearOffMenus");
@ -348,3 +354,8 @@ void InterfaceSettings::setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled
setValue(_battlefieldFlashEnabled, "battlefieldFlashEnabled");
emit battlefieldFlashEnabledChanged(_battlefieldFlashEnabled);
}
void InterfaceSettings::setUserListExpandedSections(const QStringList &sections)
{
setValue(sections, "userListExpandedSections");
}

View file

@ -44,6 +44,7 @@ public:
[[nodiscard]] bool getShowGameSelectorFilterToolbar() const override;
[[nodiscard]] bool getLifeCounterAnimationsEnabled() const override;
[[nodiscard]] bool getBattlefieldFlashEnabled() const override;
[[nodiscard]] QStringList getUserListExpandedSections() const override;
void setUseTearOffMenus(bool _useTearOffMenus);
void setCardViewInitialRowsMax(int _cardViewInitialRowsMax);
@ -78,6 +79,7 @@ public:
void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar);
void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled);
void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled);
void setUserListExpandedSections(const QStringList &sections);
signals:
void useTearOffMenusChanged(bool state);

View file

@ -5,6 +5,26 @@ TabsSettings::TabsSettings(const QString &settingPath, QObject *parent)
{
}
int TabsSettings::getStartupTabIndex() const
{
return getValue("startupTab", QString(), QString(), StartupTab::StartupTabHome).toInt();
}
QString TabsSettings::getStartupServerHost() const
{
return getValue("startupServerHost", QString(), QString(), QString()).toString();
}
QString TabsSettings::getStartupServerPort() const
{
return getValue("startupServerPort", QString(), QString(), QString()).toString();
}
QString TabsSettings::getStartupRoomName() const
{
return getValue("startupRoomName", QString(), QString(), QString()).toString();
}
bool TabsSettings::getTabVisualDeckStorageOpen() const
{
return getValue("visualDeckStorage", QString(), QString(), true).toBool();
@ -40,6 +60,42 @@ bool TabsSettings::getTabLogOpen() const
return getValue("log", QString(), QString(), true).toBool();
}
void TabsSettings::setStartupTabIndex(int value)
{
if (getStartupTabIndex() == value) {
return;
}
setValue(value, "startupTab");
emit startupTabIndexChanged(value);
}
void TabsSettings::setStartupServerHost(const QString &host)
{
if (getStartupServerHost() == host) {
return;
}
setValue(host, "startupServerHost");
emit startupServerHostChanged(host);
}
void TabsSettings::setStartupServerPort(const QString &port)
{
if (getStartupServerPort() == port) {
return;
}
setValue(port, "startupServerPort");
emit startupServerPortChanged(port);
}
void TabsSettings::setStartupRoomName(const QString &roomName)
{
if (getStartupRoomName() == roomName) {
return;
}
setValue(roomName, "startupRoomName");
emit startupRoomNameChanged(roomName);
}
void TabsSettings::setTabVisualDeckStorageOpen(bool value)
{
setValue(value, "visualDeckStorage");

View file

@ -5,12 +5,35 @@
#include <libcockatrice/interfaces/interface_tabs_settings_provider.h>
/**
* @brief The tab the application selects after launch.
*
* The destination is a request: tabs that were not open before (Deck Editor, Server Room, )
* are opened as part of the startup flow. Destinations that require a server connection use the
* intent system to satisfy their pre-conditions.
*/
enum StartupTab
{
StartupTabHome, ///< The Home tab
StartupTabVisualDeckStorage, ///< The visual deck storage tab
StartupTabDeckStorage, ///< The deck storage tab
StartupTabReplays, ///< The game replays tab
StartupTabDeckEditor, ///< A fresh classic deck editor tab
StartupTabVisualDeckEditor, ///< A fresh visual deck editor tab
StartupTabServer, ///< The server lobby: connect and select the server tab
StartupTabServerRoom ///< A server room: connect and join the room by name
};
class TabsSettings : public SettingsManager, public ITabsSettingsProvider
{
Q_OBJECT
friend class SettingsCache;
public:
[[nodiscard]] int getStartupTabIndex() const override;
[[nodiscard]] QString getStartupServerHost() const override;
[[nodiscard]] QString getStartupServerPort() const override;
[[nodiscard]] QString getStartupRoomName() const override;
[[nodiscard]] bool getTabVisualDeckStorageOpen() const override;
[[nodiscard]] bool getTabServerOpen() const override;
[[nodiscard]] bool getTabAccountOpen() const override;
@ -19,6 +42,10 @@ public:
[[nodiscard]] bool getTabAdminOpen() const override;
[[nodiscard]] bool getTabLogOpen() const override;
void setStartupTabIndex(int value);
void setStartupServerHost(const QString &host);
void setStartupServerPort(const QString &port);
void setStartupRoomName(const QString &roomName);
void setTabVisualDeckStorageOpen(bool value);
void setTabServerOpen(bool value);
void setTabAccountOpen(bool value);
@ -27,6 +54,12 @@ public:
void setTabAdminOpen(bool value);
void setTabLogOpen(bool value);
signals:
void startupTabIndexChanged(int index);
void startupServerHostChanged(const QString &host);
void startupServerPortChanged(const QString &port);
void startupRoomNameChanged(const QString &roomName);
public:
explicit TabsSettings(const QString &settingPath, QObject *parent = nullptr);

View file

@ -188,6 +188,38 @@ TEST_F(SettingsDefaultsTest, Sound_MasterVolume_SetAndGet)
// --- TabsSettings ---
TEST_F(SettingsDefaultsTest, Tabs_StartupTab_Default)
{
TabsSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getStartupTabIndex(), static_cast<int>(StartupTab::StartupTabHome));
}
TEST_F(SettingsDefaultsTest, Tabs_StartupTab_SetAndGet)
{
TabsSettings s(settingsPath, nullptr);
s.setStartupTabIndex(StartupTab::StartupTabServerRoom);
ASSERT_EQ(s.getStartupTabIndex(), static_cast<int>(StartupTab::StartupTabServerRoom));
}
TEST_F(SettingsDefaultsTest, Tabs_StartupServer_Default)
{
TabsSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getStartupServerHost(), QString());
ASSERT_EQ(s.getStartupServerPort(), QString());
ASSERT_EQ(s.getStartupRoomName(), QString());
}
TEST_F(SettingsDefaultsTest, Tabs_StartupServer_SetAndGet)
{
TabsSettings s(settingsPath, nullptr);
s.setStartupServerHost("server.cockatrice.us");
s.setStartupServerPort("4748");
s.setStartupRoomName("General");
ASSERT_EQ(s.getStartupServerHost(), QString("server.cockatrice.us"));
ASSERT_EQ(s.getStartupServerPort(), QString("4748"));
ASSERT_EQ(s.getStartupRoomName(), QString("General"));
}
TEST_F(SettingsDefaultsTest, Tabs_AllTabsOpen_Default)
{
TabsSettings s(settingsPath, nullptr);
@ -400,6 +432,23 @@ TEST_F(SettingsDefaultsTest, DeckEditor_DefaultDeckEditorType_Default)
ASSERT_EQ(s.getDefaultDeckEditorType(), 1);
}
TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_Default)
{
DeckEditorSettings s(settingsPath, nullptr);
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext);
}
TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet)
{
DeckEditorSettings s(settingsPath, nullptr);
s.setVdeStartupTab(VdeStartupTabDeckDisplay);
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabDeckDisplay);
s.setVdeStartupTab(VdeStartupTabDatabaseDisplay);
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabDatabaseDisplay);
s.setVdeStartupTab(VdeStartupTabContext);
ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext);
}
// --- NetworkSettings ---
TEST_F(SettingsDefaultsTest, Network_ClientID_Default)