diff --git a/.ci/compile.sh b/.ci/compile.sh
index 5c2eec894..aa1c71e65 100755
--- a/.ci/compile.sh
+++ b/.ci/compile.sh
@@ -159,6 +159,7 @@ if [[ $PACKAGE_TYPE ]]; then
fi
if [[ $USE_VCPKG ]]; then
flags+=("-DUSE_VCPKG=1")
+ flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
fi
# Add CMake --build flags
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 56ad64283..589cae1d8 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -334,7 +334,7 @@ the tr() call, also you can add an extra string as a hint for translators:
QString message = tr("Everyone draws %n cards", "english hint for translators", amount);
```
See [Qt's wiki on translations](
-https://doc.qt.io/qt-5/i18n-source-translation.html#handling-plurals)
+https://doc.qt.io/qt-6/i18n-source-translation.html#handling-plurals)
If you're about to propose a change that adds or modifies any translatable
string in the code, you don't need to take care of adding the new strings to
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 66c03a2d3..108e94da7 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -61,10 +61,11 @@ if(WIN32 OR USE_VCPKG) # Windows (including 64bit) or USE_VCPKG set
)
set(QTDIR
""
- CACHE PATH "Path to Qt (e.g. C:/Qt/5.7/msvc2015_64)"
+ CACHE PATH "Path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)"
)
message(
- WARNING "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/5.7/msvc2015_64)"
+ WARNING
+ "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)"
)
endif()
endif()
@@ -99,8 +100,7 @@ include(createversionfile)
# Define a proper install path
if(UNIX)
- if(APPLE)
- # macOS
+ if(APPLE) # macOS
# Due to the special bundle structure ignore
# the prefix eventually set by the user.
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
@@ -120,8 +120,7 @@ if(UNIX)
set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c")
set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
endif()
- else()
- # Linux / BSD
+ else() # Linux / BSD
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
#fix package build
if(PREFIX)
@@ -131,7 +130,7 @@ if(UNIX)
endif()
endif()
endif()
-elseif(WIN32)
+elseif(WIN32) # Windows, including 64 bit
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
endif()
@@ -254,7 +253,7 @@ set(CPACK_PACKAGE_VERSION_PATCH "${PROJECT_VERSION_PATCH}")
set(CPACK_PACKAGE_FILE_NAME "${PROJECT_VERSION_FILENAME}")
if(UNIX)
- if(APPLE)
+ if(APPLE) #macOS
set(CPACK_GENERATOR DragNDrop ${CPACK_GENERATOR})
set(CPACK_GENERATOR "DragNDrop")
set(CPACK_DMG_FORMAT "UDBZ")
@@ -264,14 +263,11 @@ if(UNIX)
set(CPACK_DMG_DS_STORE_SETUP_SCRIPT "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CMakeDMGSetup.script")
set(CPACK_DMG_BACKGROUND_IMAGE "${CMAKE_CURRENT_SOURCE_DIR}/cmake/dmgBackground.tif")
set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/SignMacApplications.cmake")
- else()
- # linux
+ else() # Linux
if(CPACK_GENERATOR STREQUAL "RPM")
set(CPACK_RPM_PACKAGE_LICENSE "GPLv2")
set(CPACK_RPM_MAIN_COMPONENT "cockatrice")
- if(Qt6_FOUND)
- set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
- endif()
+ set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice")
# stop directories from making package conflicts
@@ -289,13 +285,11 @@ if(UNIX)
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_SECTION "games")
set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice")
- if(Qt6_FOUND)
- set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
- set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
- endif()
+ set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
+ set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
endif()
endif()
-elseif(WIN32)
+elseif(WIN32) # Windows, including 64 bit
set(CPACK_GENERATOR NSIS ${CPACK_GENERATOR})
if("${CMAKE_GENERATOR_PLATFORM}" MATCHES "(x64)")
set(TRICE_IS_64_BIT 1)
@@ -352,6 +346,4 @@ if(TEST)
add_subdirectory(tests)
endif()
-if(Qt6_FOUND)
- qt6_finalize_project()
-endif()
+qt6_finalize_project()
diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake
index eecb1fcc8..78c9aa1b0 100644
--- a/cmake/FindQtRuntime.cmake
+++ b/cmake/FindQtRuntime.cmake
@@ -38,44 +38,38 @@ set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_CO
)
list(REMOVE_DUPLICATES REQUIRED_QT_COMPONENTS)
-# Find Qt and all required components, as well as Linguist
+# Find Qt and all required components including Linguist
find_package(
Qt6
COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist
QUIET HINTS ${Qt6_DIR}
)
-if(Qt6_FOUND)
- set(COCKATRICE_QT_VERSION_NAME Qt6)
- set(CMAKE_POSITION_INDEPENDENT_CODE ON)
-
- if(Qt6LinguistTools_FOUND)
- list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX)
- if(QT6_LRELEASE_INDEX EQUAL -1)
- message(WARNING "Qt6 lrelease not found.")
- endif()
-
- list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX)
- if(QT6_LUPDATE_INDEX EQUAL -1)
- message(WARNING "Qt6 lupdate not found.")
- endif()
- else()
- message(WARNING "Linguist Tools not found, cannot handle translations")
- endif()
-else()
- message(FATAL_ERROR "Qt6 not found")
+if(NOT Qt6_FOUND)
+ message(FATAL_ERROR "No suitable version of Qt was found")
endif()
+set(COCKATRICE_QT_VERSION_NAME Qt6)
+
+list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX)
+if(QT6_LRELEASE_INDEX EQUAL -1)
+ message(WARNING "Qt6 lrelease not found.")
+endif()
+
+list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX)
+if(QT6_LUPDATE_INDEX EQUAL -1)
+ message(WARNING "Qt6 lupdate not found.")
+endif()
+
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Establish Qt Plugins directory & Library directories
get_target_property(QT_LIBRARY_DIR ${COCKATRICE_QT_VERSION_NAME}::Core LOCATION)
get_filename_component(QT_LIBRARY_DIR ${QT_LIBRARY_DIR} DIRECTORY)
-if(Qt6_FOUND)
- get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE)
- get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE)
- if(UNIX AND APPLE)
- # Mac needs a bit more help finding all necessary components
- list(APPEND QT_LIBRARY_DIR "/usr/local/lib")
- endif()
+get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE)
+get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE)
+if(UNIX AND APPLE)
+ # Mac needs a bit more help finding all necessary components
+ list(APPEND QT_LIBRARY_DIR "/usr/local/lib")
endif()
message(DEBUG "QT_PLUGINS_DIR = ${QT_PLUGINS_DIR}")
message(DEBUG "QT_LIBRARY_DIR = ${QT_LIBRARY_DIR}")
diff --git a/cmake/Info.plist b/cmake/Info.plist
index 614d82509..7f01befcb 100644
--- a/cmake/Info.plist
+++ b/cmake/Info.plist
@@ -1,38 +1,118 @@
-
+
+
+
+
+
+
CFBundleDevelopmentRegion
English
+
CFBundleExecutable
${MACOSX_BUNDLE_EXECUTABLE_NAME}
+
CFBundleGetInfoString
${MACOSX_BUNDLE_INFO_STRING}
+
CFBundleIconFile
${MACOSX_BUNDLE_ICON_FILE}
+
CFBundleIdentifier
${MACOSX_BUNDLE_GUI_IDENTIFIER}
+
CFBundleInfoDictionaryVersion
6.0
+
CFBundleLongVersionString
${MACOSX_BUNDLE_LONG_VERSION_STRING}
+
CFBundleName
${MACOSX_BUNDLE_BUNDLE_NAME}
+
CFBundlePackageType
APPL
+
CFBundleShortVersionString
${MACOSX_BUNDLE_SHORT_VERSION_STRING}
+
CFBundleSignature
????
+
CFBundleVersion
${MACOSX_BUNDLE_BUNDLE_VERSION}
- CSResourcesFileMapped
-
- LSRequiresCarbon
-
+
NSHumanReadableCopyright
${MACOSX_BUNDLE_COPYRIGHT}
+
NSHighResolutionCapable
+
+
+
+
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ org.cockatrice.deck
+
+ UTTypeDescription
+ Cockatrice Deck
+
+ UTTypeConformsTo
+
+ public.data
+
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ cod
+
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Cockatrice Deck
+
+ CFBundleTypeRole
+ Editor
+
+ LSHandlerRank
+ Default
+
+ LSItemContentTypes
+
+ org.cockatrice.deck
+
+
+
+
+
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ Cockatrice URL Scheme
+
+ CFBundleURLSchemes
+
+ cockatrice
+
+
+
+
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index a7885d33f..a15fda582 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -99,6 +99,7 @@ set(cockatrice_SOURCES
src/game_graphics/player/menu/sideboard_menu.cpp
src/game_graphics/player/menu/tally_menu.cpp
src/game_graphics/player/menu/utility_menu.cpp
+ src/game_graphics/tally/stats_tally.cpp
src/game_graphics/tally/subtype_tally.cpp
src/game_graphics/tally/tally.cpp
src/game/player/player_actions.cpp
@@ -134,6 +135,12 @@ set(cockatrice_SOURCES
src/interface/card_picture_loader/card_picture_loader_worker.cpp
src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
src/interface/card_picture_loader/card_picture_to_load.cpp
+ src/interface/intents/intent.cpp
+ src/interface/intents/intent.h
+ src/interface/intents/intent_open_local_deck.cpp
+ src/interface/intents/intent_open_local_deck.h
+ src/interface/intents/intent_wait_for_database_load.cpp
+ src/interface/intents/intent_wait_for_database_load.h
src/interface/layouts/flow_layout.cpp
src/interface/layouts/overlap_layout.cpp
src/interface/widgets/utility/line_edit_completer.cpp
@@ -229,6 +236,7 @@ set(cockatrice_SOURCES
src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp
src/interface/widgets/quick_settings/settings_button_widget.cpp
src/interface/widgets/quick_settings/settings_popup_widget.cpp
+ src/interface/widgets/replay/replay_manager.cpp
src/interface/widgets/replay/replay_quick_settings_widget.cpp
src/interface/widgets/replay/replay_timeline_widget.cpp
src/interface/widgets/replay/replay_widget.cpp
@@ -249,10 +257,13 @@ set(cockatrice_SOURCES
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_widget.cpp
+ src/interface/widgets/settings_page/abstract_settings_page.cpp
src/interface/widgets/settings_page/appearance_settings_page.cpp
src/interface/widgets/settings_page/deck_editor_settings_page.cpp
src/interface/widgets/settings_page/general_settings_page.cpp
src/interface/widgets/settings_page/messages_settings_page.cpp
+ src/interface/widgets/settings_page/settings_search_delegate.cpp
+ src/interface/widgets/settings_page/settings_search_model.cpp
src/interface/widgets/settings_page/shortcut_settings_page.cpp
src/interface/widgets/settings_page/sound_settings_page.cpp
src/interface/widgets/settings_page/storage_settings_page.cpp
@@ -291,6 +302,7 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
src/interface/window_main.cpp
src/main.cpp
+ src/single_instance_manager.cpp
src/interface/widgets/tabs/abstract_tab_deck_editor.cpp
src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp
src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp
@@ -306,6 +318,13 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp
src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp
src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp
src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp
src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp
src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp
@@ -352,12 +371,29 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp
src/interface/key_signals.cpp
src/interface/logger.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
+ src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
src/interface/widgets/utility/compact_push_button.cpp
src/interface/widgets/utility/compact_push_button.h
+ src/single_instance_manager.h
+ src/client/url_scheme_event_filter.h
+ src/interface/intents/intent_connect_to_server.cpp
+ src/interface/intents/intent_connect_to_server.h
+ src/interface/intents/intent_disconnect_from_server.cpp
+ src/interface/intents/intent_disconnect_from_server.h
+ src/interface/intents/intent_join_server_game.cpp
+ src/interface/intents/intent_join_server_game.h
+ src/interface/intents/intent_join_server_room.cpp
+ src/interface/intents/intent_join_server_room.h
+ src/interface/intents/intent_login.cpp
+ src/interface/intents/intent_login.h
+ src/interface/intents/url_parser.cpp
+ src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp
src/interface/widgets/server/user/user_info_popup.h
)
@@ -401,9 +437,7 @@ if(APPLE)
set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns)
endif(APPLE)
-if(Qt6_FOUND)
- qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
-endif()
+qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES})
# Declare path variables
set(ICONDIR
@@ -415,34 +449,37 @@ set(DESKTOPDIR
CACHE STRING "desktop file destination"
)
+set(MIMEDIR
+ share/mime/packages
+ CACHE STRING "mime file destination"
+)
+
set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translations")
set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations")
set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations")
-if(Qt6_FOUND)
- qt6_add_executable(
- cockatrice
- WIN32
- MACOSX_BUNDLE
- ${cockatrice_SOURCES}
- ${cockatrice_RESOURCES_RCC}
- ${cockatrice_MOC_SRCS}
- MANUAL_FINALIZATION
- )
+qt6_add_executable(
+ cockatrice
+ WIN32
+ MACOSX_BUNDLE
+ ${cockatrice_SOURCES}
+ ${cockatrice_RESOURCES_RCC}
+ ${cockatrice_MOC_SRCS}
+ MANUAL_FINALIZATION
+)
- target_link_libraries(
- cockatrice
- PUBLIC libcockatrice_card
- libcockatrice_deck_list
- libcockatrice_filters
- libcockatrice_utility
- libcockatrice_network
- libcockatrice_models
- libcockatrice_rng
- libcockatrice_settings
- ${COCKATRICE_QT_MODULES}
- )
-endif()
+target_link_libraries(
+ cockatrice
+ PUBLIC libcockatrice_card
+ libcockatrice_deck_list
+ libcockatrice_filters
+ libcockatrice_utility
+ libcockatrice_network
+ libcockatrice_models
+ libcockatrice_rng
+ libcockatrice_settings
+ ${COCKATRICE_QT_MODULES}
+)
if(UNIX)
if(APPLE)
@@ -462,6 +499,23 @@ if(UNIX)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.png DESTINATION ${ICONDIR}/hicolor/48x48/apps)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.svg DESTINATION ${ICONDIR}/hicolor/scalable/apps)
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice.desktop DESTINATION ${DESKTOPDIR})
+ install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice-cod.xml DESTINATION ${MIMEDIR})
+
+ # Refresh the freedesktop databases so the file associations and scheme
+ # handler register without requiring the user to run them manually. The
+ # tools may be missing on minimal systems; that is fine, packaging systems
+ # usually refresh these databases through their own triggers.
+ find_program(UPDATE_MIME_DATABASE update-mime-database)
+ if(UPDATE_MIME_DATABASE)
+ install(CODE "execute_process(COMMAND \"${UPDATE_MIME_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/mime\")")
+ endif()
+
+ find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
+ if(UPDATE_DESKTOP_DATABASE)
+ install(
+ CODE "execute_process(COMMAND \"${UPDATE_DESKTOP_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/applications\")"
+ )
+ endif()
endif()
elseif(WIN32)
install(TARGETS cockatrice RUNTIME DESTINATION ./)
@@ -590,7 +644,7 @@ Data = Resources\")
endif()
endif()
-if(Qt6_FOUND AND Qt6LinguistTools_FOUND)
+if(Qt6LinguistTools_FOUND)
#Qt6 Translations happen after the executable is built up
if(UPDATE_TRANSLATIONS)
qt6_add_translations(
@@ -617,6 +671,4 @@ if(Qt6_FOUND AND Qt6LinguistTools_FOUND)
endif()
endif()
-if(Qt6_FOUND)
- qt6_finalize_target(cockatrice)
-endif()
+qt6_finalize_target(cockatrice)
diff --git a/cockatrice/cockatrice-cod.xml b/cockatrice/cockatrice-cod.xml
new file mode 100644
index 000000000..1a0199433
--- /dev/null
+++ b/cockatrice/cockatrice-cod.xml
@@ -0,0 +1,7 @@
+
+
+
+ Cockatrice Deck File
+
+
+
diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop
index 092d84ef5..4b15fa9c1 100644
--- a/cockatrice/cockatrice.desktop
+++ b/cockatrice/cockatrice.desktop
@@ -3,6 +3,8 @@
Version=1.0
Type=Application
Name=Cockatrice
-Exec=cockatrice
+Exec=cockatrice %U
Icon=cockatrice
Categories=Game;CardGame;
+MimeType=application/x-cockatrice;
+X-Scheme-Handler/cockatrice=true
diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp
index 480ff701d..dae633717 100644
--- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp
+++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp
@@ -14,8 +14,8 @@
#include
#include
#include
+#include
#include
-#include
#include
#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"
@@ -23,7 +23,7 @@
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr)
{
- isSpoilerDownloadEnabled = SettingsCache::instance().personal().getDownloadSpoilersStatus();
+ isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus();
if (isSpoilerDownloadEnabled) {
// Start the process of checking if we're in spoiler season
// File exists means we're in spoiler season
diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp
index 4f36bbb3b..eaa5d96ac 100644
--- a/cockatrice/src/client/settings/cache_settings.cpp
+++ b/cockatrice/src/client/settings/cache_settings.cpp
@@ -11,18 +11,22 @@
#include
#include
#include
+#include
#include
#include
#include
#include
#include
+#include
#include
+#include
#include
#include
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -135,8 +139,12 @@ SettingsCache::SettingsCache()
personalSettings = new PersonalSettings(settingsPath, this);
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
interfaceSettings = new InterfaceSettings(settingsPath, this);
+ deckEditorSettings = new DeckEditorSettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this);
+ appearanceSettings = new AppearanceSettings(settingsPath, this);
+ networkSettings = new NetworkSettings(settingsPath, this);
+ commanderBracketSettings = new CommanderBracketSettings(settingsPath, this);
// Forward ICardDatabasePathProvider signal from PathsSettings
connect(pathsSettings, &PathsSettings::cardDatabasePathChanged, this,
@@ -147,7 +155,13 @@ SettingsCache::SettingsCache()
releaseChannels << new StableReleaseChannel();
releaseChannels << new BetaReleaseChannel();
- themeName = personalSettings->getThemeName();
+ themeName = appearanceSettings->getThemeName();
+
+ auto definitions = commanderBracketSettings->loadDefinitions();
+ if (definitions.isEmpty()) {
+ definitions = CommanderBracketSettings::defaultDefinitions();
+ }
+ commanderBracketSettings->reloadDefinitions(definitions);
loadPaths();
}
@@ -155,7 +169,7 @@ SettingsCache::SettingsCache()
void SettingsCache::setThemeName(const QString &_themeName)
{
themeName = _themeName;
- personalSettings->setThemeName(themeName);
+ appearanceSettings->setThemeName(themeName);
emit themeChanged();
}
@@ -216,15 +230,15 @@ void SettingsCache::loadPaths()
// customPicsPath derived from picsPath
QString picsPath = pathsIni.value("paths/pics").toString();
if (picsPath.endsWith("/")) {
- computePath("custompics", picsPath + "CUSTOM/");
+ computePath("customPics", picsPath + "CUSTOM/");
} else {
- computePath("custompics", picsPath + "/CUSTOM/");
+ computePath("customPics", picsPath + "/CUSTOM/");
}
- computePath("customsets", dataPath + "/customsets/");
- computeFilePath("carddatabase", dataPath + "/cards.xml");
- computeFilePath("tokendatabase", dataPath + "/tokens.xml");
- computeFilePath("spoilerdatabase", dataPath + "/spoiler.xml");
+ computePath("customSets", dataPath + "/customsets/");
+ computeFilePath("cardDatabase", dataPath + "/cards.xml");
+ computeFilePath("tokenDatabase", dataPath + "/tokens.xml");
+ computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml");
}
void SettingsCache::resetPaths()
@@ -272,12 +286,12 @@ QString SettingsCache::getTokenDatabasePath() const
// INetworkSettingsProvider - delegate to sub-objects
int SettingsCache::getKeepAlive() const
{
- return personalSettings->getKeepAlive();
+ return networkSettings->getKeepAlive();
}
int SettingsCache::getTimeOut() const
{
- return personalSettings->getTimeOut();
+ return networkSettings->getTimeOut();
}
bool SettingsCache::getNotifyAboutUpdates() const
@@ -287,17 +301,17 @@ bool SettingsCache::getNotifyAboutUpdates() const
void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures)
{
- interfaceSettings->setKnownMissingFeatures(_knownMissingFeatures);
+ networkSettings->setKnownMissingFeatures(_knownMissingFeatures);
}
QString SettingsCache::getKnownMissingFeatures()
{
- return interfaceSettings->getKnownMissingFeatures();
+ return networkSettings->getKnownMissingFeatures();
}
QString SettingsCache::getClientID()
{
- return personalSettings->getClientID();
+ return networkSettings->getClientID();
}
// Release channels
@@ -412,7 +426,7 @@ CardsDisplaySettings &SettingsCache::cardsDisplay() const
return *cardsDisplaySettings;
}
-InterfaceSettings &SettingsCache::interface() const
+InterfaceSettings &SettingsCache::userInterface() const
{
return *interfaceSettings;
}
@@ -422,7 +436,22 @@ PathsSettings &SettingsCache::paths() const
return *pathsSettings;
}
+DeckEditorSettings &SettingsCache::deckEditor() const
+{
+ return *deckEditorSettings;
+}
+
VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const
{
return *visualDeckStorageSettings;
}
+
+AppearanceSettings &SettingsCache::appearance() const
+{
+ return *appearanceSettings;
+}
+
+NetworkSettings &SettingsCache::network() const
+{
+ return *networkSettings;
+}
diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h
index f9cce4cfe..23cdb4dbf 100644
--- a/cockatrice/src/client/settings/cache_settings.h
+++ b/cockatrice/src/client/settings/cache_settings.h
@@ -28,7 +28,9 @@ class CardDatabaseSettings;
class CardOverrideSettings;
class CardsDisplaySettings;
class ChatSettings;
+class CommanderBracketSettings;
class DebugSettings;
+class DeckEditorSettings;
class DownloadSettings;
class GameFiltersSettings;
class GameSettings;
@@ -44,6 +46,8 @@ class SoundSettings;
class TabsSettings;
class UpdatesSettings;
class VisualDeckStorageSettings;
+class AppearanceSettings;
+class NetworkSettings;
class QSettings;
class SettingsCache : public ICardDatabasePathProvider, public INetworkSettingsProvider
@@ -75,8 +79,12 @@ private:
PersonalSettings *personalSettings;
CardsDisplaySettings *cardsDisplaySettings;
InterfaceSettings *interfaceSettings;
+ DeckEditorSettings *deckEditorSettings;
PathsSettings *pathsSettings;
VisualDeckStorageSettings *visualDeckStorageSettings;
+ AppearanceSettings *appearanceSettings;
+ NetworkSettings *networkSettings;
+ CommanderBracketSettings *commanderBracketSettings;
QString themeName;
@@ -138,9 +146,16 @@ public:
[[nodiscard]] UpdatesSettings &updates() const;
[[nodiscard]] PersonalSettings &personal() const;
[[nodiscard]] CardsDisplaySettings &cardsDisplay() const;
- [[nodiscard]] InterfaceSettings &interface() const;
+ [[nodiscard]] InterfaceSettings &userInterface() const;
+ [[nodiscard]] DeckEditorSettings &deckEditor() const;
[[nodiscard]] PathsSettings &paths() const;
[[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const;
+ [[nodiscard]] AppearanceSettings &appearance() const;
+ [[nodiscard]] NetworkSettings &network() const;
+ [[nodiscard]] CommanderBracketSettings &commanderBrackets() const
+ {
+ return *commanderBracketSettings;
+ }
[[nodiscard]] bool getIsPortableBuild() const
{
diff --git a/cockatrice/src/client/settings/card_counter_settings.cpp b/cockatrice/src/client/settings/card_counter_settings.cpp
index 662ae0c7d..d4030c174 100644
--- a/cockatrice/src/client/settings/card_counter_settings.cpp
+++ b/cockatrice/src/client/settings/card_counter_settings.cpp
@@ -5,7 +5,7 @@
#include
CardCounterSettings::CardCounterSettings(const QString &settingsPath, QObject *parent)
- : SettingsManager(settingsPath + "global.ini", "cards", "counters", parent)
+ : SettingsManager(settingsPath + "card_counters.ini", "cards", "counters", parent)
{
}
diff --git a/cockatrice/src/client/url_scheme_event_filter.h b/cockatrice/src/client/url_scheme_event_filter.h
new file mode 100644
index 000000000..9e96502ca
--- /dev/null
+++ b/cockatrice/src/client/url_scheme_event_filter.h
@@ -0,0 +1,69 @@
+#ifndef COCKATRICE_URL_SCHEME_EVENT_FILTER_H
+#define COCKATRICE_URL_SCHEME_EVENT_FILTER_H
+
+#include
+#include
+#include
+#include
+#include
+
+/**
+ * @brief Event filter that catches QFileOpenEvent URLs matching a scheme and
+ * re-emits them as urlReceived().
+ *
+ * On macOS, when the application is registered as a URL scheme handler, the
+ * OS delivers incoming URLs via QFileOpenEvent on the QApplication object.
+ * Install this filter on QApplication to intercept them:
+ *
+ * @code
+ * UrlSchemeEventFilter filter(QStringList{QStringLiteral("cockatrice")});
+ * QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived,
+ * &mainWindow, &MainWindow::handleUrl);
+ * app.installEventFilter(&filter);
+ * @endcode
+ *
+ * Note: the strings are compared against QUrl::scheme(), so they must be
+ * written without the "://" suffix (e.g. "cockatrice", not "cockatrice://").
+ */
+class UrlSchemeEventFilter : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit UrlSchemeEventFilter(const QStringList &schemes, QObject *parent = nullptr)
+ : QObject(parent), prefixes(schemes)
+ {
+ }
+
+signals:
+ void urlReceived(const QString &url);
+
+public:
+ bool eventFilter(QObject *watched, QEvent *event) override
+ {
+ if (event->type() == QEvent::FileOpen) {
+ auto *fileEvent = static_cast(event);
+
+ const QUrl url = fileEvent->url();
+
+ for (const auto &prefix : prefixes) {
+ if (url.scheme() == prefix) {
+ emit urlReceived(url.toString());
+ return true;
+ }
+ }
+
+ if (url.isLocalFile()) {
+ emit urlReceived(url.toLocalFile());
+ return true;
+ }
+ }
+
+ return QObject::eventFilter(watched, event);
+ }
+
+private:
+ QStringList prefixes;
+};
+
+#endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H
diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp
index c20003ece..6aa2ab28f 100644
--- a/cockatrice/src/game/abstract_game.cpp
+++ b/cockatrice/src/game/abstract_game.cpp
@@ -31,7 +31,7 @@ AbstractClient *AbstractGame::getClientForPlayer(int playerId) const
}
}
-void AbstractGame::loadReplay(GameReplay *replay)
+void AbstractGame::loadReplay(const GameReplay *replay)
{
gameMetaInfo->setFromProto(replay->game_info());
gameMetaInfo->setSpectatorsOmniscient(true);
diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h
index 5115ed5ca..fcf764492 100644
--- a/cockatrice/src/game/abstract_game.h
+++ b/cockatrice/src/game/abstract_game.h
@@ -53,7 +53,7 @@ public:
AbstractClient *getClientForPlayer(int playerId) const;
- void loadReplay(GameReplay *replay);
+ void loadReplay(const GameReplay *replay);
CardItem *getCard(int playerId, const QString &zoneName, int cardId) const;
diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp
index 12abb994f..67c3295d6 100644
--- a/cockatrice/src/game/player/player_actions.cpp
+++ b/cockatrice/src/game/player/player_actions.cpp
@@ -69,7 +69,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown)
const CardInfo &info = exactCard.getInfo();
int tableRow = info.getUiAttributes().tableRow;
- bool playToStack = SettingsCache::instance().interface().getPlayToStack();
+ bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
QString currentZone = card->getZone()->getName();
if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) {
cmd.set_target_zone(ZoneNames::GRAVE);
@@ -312,7 +312,7 @@ void PlayerActions::actDrawCard()
void PlayerActions::actRequestMulliganDialog()
{
- int startSize = SettingsCache::instance().interface().getStartingHandSize();
+ int startSize = SettingsCache::instance().userInterface().getStartingHandSize();
int handSize = player->getHandZone()->getCards().size();
int deckSize = player->getDeckZone()->getCards().size() + handSize;
@@ -328,7 +328,7 @@ void PlayerActions::actMulligan(int number)
}
doMulligan(number);
- SettingsCache::instance().interface().setStartingHandSize(number);
+ SettingsCache::instance().userInterface().setStartingHandSize(number);
}
void PlayerActions::actMulliganSameSize()
@@ -932,13 +932,13 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo)
return;
}
- lastTokenInfo = {.name = cardInfo->getName(),
- .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(),
- .pt = cardInfo->getPowTough(),
- .annotation = SettingsCache::instance().interface().getAnnotateTokens() ? cardInfo->getText() : "",
- .destroy = true,
- .providerId =
- SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())};
+ lastTokenInfo = {
+ .name = cardInfo->getName(),
+ .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(),
+ .pt = cardInfo->getPowTough(),
+ .annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "",
+ .destroy = true,
+ .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())};
lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow);
@@ -1171,7 +1171,7 @@ void PlayerActions::createCard(const CardItem *sourceCard,
}
cmd.set_pt(cardInfo->getPowTough().toStdString());
- if (SettingsCache::instance().interface().getAnnotateTokens()) {
+ if (SettingsCache::instance().userInterface().getAnnotateTokens()) {
cmd.set_annotation(cardInfo->getText().toStdString());
} else {
cmd.set_annotation("");
diff --git a/cockatrice/src/game/replay.cpp b/cockatrice/src/game/replay.cpp
index 69f9d8b20..dcf3e9b9b 100644
--- a/cockatrice/src/game/replay.cpp
+++ b/cockatrice/src/game/replay.cpp
@@ -2,7 +2,7 @@
#include "../interface/widgets/tabs/tab_game.h"
-Replay::Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent)
+Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent)
{
gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false);
connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged);
diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h
index ecb3a10d0..1c269b273 100644
--- a/cockatrice/src/game/replay.h
+++ b/cockatrice/src/game/replay.h
@@ -15,7 +15,7 @@ class Replay : public AbstractGame
Q_OBJECT
public:
- explicit Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame);
+ explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame);
};
#endif // COCKATRICE_REPLAY_H
diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp
index 60fe39bb2..2ef04284a 100644
--- a/cockatrice/src/game/zones/view_zone_logic.cpp
+++ b/cockatrice/src/game/zones/view_zone_logic.cpp
@@ -58,7 +58,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x)
// autoclose check is done both here and in removeCard
- if (cards.isEmpty() && !doInsert && SettingsCache::instance().interface().getCloseEmptyCardView()) {
+ if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) {
emit closeView();
}
@@ -145,7 +145,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone)
// card gets dragged within the view.
// Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an
// unrevealed portion of the same zone.
- if (cards.isEmpty() && SettingsCache::instance().interface().getCloseEmptyCardView() && toNewZone) {
+ if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) {
emit closeView();
return;
}
diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
index a9e0167d4..e0029ee2d 100644
--- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp
@@ -12,9 +12,9 @@
#include
#include
#include
+#include
#include
#include
-#include
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
@@ -107,7 +107,7 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const
void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle)
{
- const int MAX_FONT_SIZE = SettingsCache::instance().personal().getMaxFontSize();
+ const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize();
const int fontSize = std::max(9, MAX_FONT_SIZE);
QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect());
diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp
index c40827361..ce8967bb5 100644
--- a/cockatrice/src/game_graphics/board/arrow_item.cpp
+++ b/cockatrice/src/game_graphics/board/arrow_item.cpp
@@ -262,7 +262,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (startZone->getName() == ZoneNames::HAND) {
startCard->playCard(false);
CardInfoPtr ci = startCard->getCard().getCardPtr();
- bool playToStack = SettingsCache::instance().interface().getPlayToStack();
+ bool playToStack = SettingsCache::instance().userInterface().getPlayToStack();
if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) ||
(playToStack && ci->getUiAttributes().tableRow != 0 &&
startCard->getZone()->getName() != ZoneNames::STACK))) {
diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp
index 63e298886..c40c8c214 100644
--- a/cockatrice/src/game_graphics/board/card_item.cpp
+++ b/cockatrice/src/game_graphics/board/card_item.cpp
@@ -281,7 +281,7 @@ void CardItem::drawArrow(const QColor &arrowColor)
auto *game = owner->getGame();
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
int phase = 0; // 0 means to not set the phase
- if (SettingsCache::instance().interface().getDoNotDeleteArrowsInSubPhases()) {
+ if (SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()) {
int currentPhase = game->getGameState()->getCurrentPhase();
phase = Phases::getLastSubphase(currentPhase) + 1;
}
@@ -400,7 +400,7 @@ void CardItem::playCard(bool faceDown)
if (tz) {
emit tz->toggleTapped();
} else {
- if (SettingsCache::instance().interface().getClickPlaysAllSelected()) {
+ if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
if (faceDown) {
emit playSelectedFaceDown(this);
} else {
@@ -464,7 +464,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone)
void CardItem::handleClickedToPlay(bool shiftHeld)
{
if (isUnwritableRevealZone(state->getZone())) {
- if (SettingsCache::instance().interface().getClickPlaysAllSelected()) {
+ if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) {
emit hideSelected(this);
} else {
state->getZone()->removeCard(this);
@@ -481,7 +481,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
return;
}
if ((event->modifiers() != Qt::AltModifier) && (event->button() == Qt::LeftButton) &&
- (!SettingsCache::instance().interface().getDoubleClickToPlay())) {
+ (!SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
}
if (owner != nullptr) {
@@ -493,7 +493,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
{
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
- (SettingsCache::instance().interface().getDoubleClickToPlay())) {
+ (SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
}
event->accept();
diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp
index e53069025..b311d2ebd 100644
--- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp
+++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp
@@ -189,7 +189,7 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo
const QChar cardColor = cardInfo->getColorChar();
colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString));
ptEdit->setText(cardInfo->getPowTough());
- if (SettingsCache::instance().interface().getAnnotateTokens()) {
+ if (SettingsCache::instance().userInterface().getAnnotateTokens()) {
annotationEdit->setText(cardInfo->getText());
}
} else {
diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp
index 58e6888c6..db2088104 100644
--- a/cockatrice/src/game_graphics/game_scene.cpp
+++ b/cockatrice/src/game_graphics/game_scene.cpp
@@ -37,7 +37,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
{
animationTimer = new QBasicTimer;
addItem(phasesToolbar);
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
&GameScene::rearrange);
rearrange();
@@ -336,7 +336,7 @@ QList GameScene::rotatePlayers(const QList &active
int GameScene::determineColumnCount(int playerCount)
{
- return playerCount < SettingsCache::instance().interface().getMinPlayersForMultiColumnLayout() ? 1 : 2;
+ return playerCount < SettingsCache::instance().userInterface().getMinPlayersForMultiColumnLayout() ? 1 : 2;
}
/**
diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp
index bda5ea76d..b768c8317 100644
--- a/cockatrice/src/game_graphics/game_view.cpp
+++ b/cockatrice/src/game_graphics/game_view.cpp
@@ -47,11 +47,11 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
connect(scene, &GameScene::sigResizeRubberBand, this, &GameView::resizeRubberBand);
connect(scene, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand);
connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); });
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::tallyTypeChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::tallyTypeChanged, this,
[this] { updateTotalSelectionCount(); });
- setFocusDisabled(SettingsCache::instance().interface().getKeepGameChatFocus());
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::keepGameChatFocusChanged, this,
+ setFocusDisabled(SettingsCache::instance().userInterface().getKeepGameChatFocus());
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, this,
&GameView::setFocusDisabled);
aCloseMostRecentZoneView = new QAction(this);
@@ -130,7 +130,7 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount)
QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized();
rubberBand->setGeometry(rect);
- if (!SettingsCache::instance().interface().getShowDragSelectionCount()) {
+ if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) {
dragCountLabel->hide();
return;
}
@@ -239,7 +239,7 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize)
int count = scene()->selectedItems().count();
- if (!SettingsCache::instance().interface().getShowTotalSelectionCount() || count <= 1) {
+ if (!SettingsCache::instance().userInterface().getShowTotalSelectionCount() || count <= 1) {
totalCountLabel->hide();
} else {
totalCountLabel->setText(QString::number(count));
@@ -251,12 +251,12 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize)
totalCountLabel->show();
}
- TallyType tallyType = Tally::intToType(SettingsCache::instance().interface().getTallyType());
+ TallyType tallyType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType());
GameScene *gameScene = static_cast(scene());
QList entries = Tally::compute(gameScene->selectedCards(), tallyType);
- if (entries.isEmpty() || count <= 1) {
+ if (entries.isEmpty()) {
tallyContainer->hide();
cachedTallyRows.clear();
return;
diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
index b8616c75a..7eb3945b3 100644
--- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
+++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
@@ -11,24 +11,26 @@ TallyMenu::TallyMenu()
aTallyNone = createTallyAction(TallyType::None);
aTallySubtypes = createTallyAction(TallyType::Subtypes);
+ aTallyTotalPower = createTallyAction(TallyType::TotalPower);
addAction(aTallyNone);
addSeparator();
addAction(aTallySubtypes);
+ addAction(aTallyTotalPower);
retranslateUi();
}
QAction *TallyMenu::createTallyAction(TallyType tallyType)
{
- TallyType currentType = Tally::intToType(SettingsCache::instance().interface().getTallyType());
+ TallyType currentType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType());
QAction *action = new QAction(this);
action->setCheckable(true);
action->setChecked(tallyType == currentType);
- connect(action, &QAction::triggered, &SettingsCache::instance().interface(),
- [tallyType] { SettingsCache::instance().interface().setTallyType(static_cast(tallyType)); });
+ connect(action, &QAction::triggered, &SettingsCache::instance().userInterface(),
+ [tallyType] { SettingsCache::instance().userInterface().setTallyType(static_cast(tallyType)); });
actionGroup->addAction(action);
@@ -51,4 +53,5 @@ void TallyMenu::retranslateUi()
aTallyNone->setText(tr("None"));
aTallySubtypes->setText(tr("Subtypes"));
+ aTallyTotalPower->setText(tr("Total Power"));
}
diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h
index 28c056f44..acd1daf67 100644
--- a/cockatrice/src/game_graphics/player/menu/tally_menu.h
+++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h
@@ -23,6 +23,7 @@ private:
QAction *aTallyNone = nullptr;
QAction *aTallySubtypes = nullptr;
+ QAction *aTallyTotalPower = nullptr;
QAction *createTallyAction(TallyType tallyType);
};
diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
index 20f6128f4..d443853ce 100644
--- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp
+++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
@@ -17,9 +17,9 @@
PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player)
{
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::horizontalHandChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::horizontalHandChanged, this,
&PlayerGraphicsItem::rearrangeZones);
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::handJustificationChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::handJustificationChanged, this,
&PlayerGraphicsItem::rearrangeZones);
connect(player, &PlayerLogic::rearrangeCounters, this, &PlayerGraphicsItem::rearrangeCounters);
connect(player, &PlayerLogic::activeChanged, this, &PlayerGraphicsItem::onPlayerActiveChanged);
@@ -149,7 +149,7 @@ qreal PlayerGraphicsItem::getMinimumWidth() const
{
qreal result = tableZoneGraphicsItem->getMinimumWidth() + CardDimensions::HEIGHT_F + 15 + counterAreaWidth +
stackZoneGraphicsItem->boundingRect().width();
- if (!SettingsCache::instance().interface().getHorizontalHand()) {
+ if (!SettingsCache::instance().userInterface().getHorizontalHand()) {
result += handZoneGraphicsItem->boundingRect().width();
}
return result;
@@ -166,7 +166,7 @@ void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth)
// Extend table (and hand, if horizontal) to accommodate the new player width.
qreal tableWidth = newPlayerWidth - CardDimensions::HEIGHT_F - 15 - counterAreaWidth -
stackZoneGraphicsItem->boundingRect().width();
- if (!SettingsCache::instance().interface().getHorizontalHand()) {
+ if (!SettingsCache::instance().userInterface().getHorizontalHand()) {
tableWidth -= handZoneGraphicsItem->boundingRect().width();
}
@@ -234,7 +234,7 @@ void PlayerGraphicsItem::rearrangeCounters()
void PlayerGraphicsItem::rearrangeZones()
{
auto base = QPointF(CardDimensions::HEIGHT_F + counterAreaWidth + 15, 0);
- if (SettingsCache::instance().interface().getHorizontalHand()) {
+ if (SettingsCache::instance().userInterface().getHorizontalHand()) {
if (mirrored) {
if (player->getHandZone()->contentsKnown()) {
handVisible = true;
@@ -285,7 +285,7 @@ void PlayerGraphicsItem::updateBoundingRect()
{
prepareGeometryChange();
qreal width = CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width();
- if (SettingsCache::instance().interface().getHorizontalHand()) {
+ if (SettingsCache::instance().userInterface().getHorizontalHand()) {
qreal handHeight = handVisible ? handZoneGraphicsItem->boundingRect().height() : 0;
bRect = QRectF(0, 0, width + tableZoneGraphicsItem->boundingRect().width(),
tableZoneGraphicsItem->boundingRect().height() + handHeight);
diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp
new file mode 100644
index 000000000..e7a6621fa
--- /dev/null
+++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp
@@ -0,0 +1,36 @@
+#include "stats_tally.h"
+
+#include "../board/card_item.h"
+
+#include
+#include
+#include
+
+static int sumPowers(const QList &cards)
+{
+ // calculate total power;
+ int total = 0;
+ for (auto card : cards) {
+ QVariantList parsed = CardItem::parsePT(card->getPT());
+ if (!parsed.isEmpty()) {
+ int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int
+ total += qMax(power, 0);
+ }
+ }
+ return total;
+}
+
+QList StatsTally::computeTotalPower(const QList &cards)
+{
+ // don't bother if none of the cards have pt
+ bool hasPT =
+ std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); });
+ if (!hasPT) {
+ return {};
+ }
+
+ int total = sumPowers(cards);
+
+ QString name = QCoreApplication::translate("StatsTally", "Total Power");
+ return {TallyRow{name, QString::number(total)}};
+}
diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h
new file mode 100644
index 000000000..4c3d93b56
--- /dev/null
+++ b/cockatrice/src/game_graphics/tally/stats_tally.h
@@ -0,0 +1,21 @@
+#ifndef COCKATRICE_STATS_TALLY_H
+#define COCKATRICE_STATS_TALLY_H
+#include "tally.h"
+
+/**
+ * @brief Extracts and tallies stats from selected cards.
+ */
+namespace StatsTally
+{
+
+/**
+ * @brief Sums the power of all selected cards
+ *
+ * @param cards The list of selected card items to analyze.
+ * @return A single row containing the total, or an empty list if none of the cards have pt
+ */
+QList computeTotalPower(const QList &cards);
+
+} // namespace StatsTally
+
+#endif // COCKATRICE_STATS_TALLY_H
diff --git a/cockatrice/src/game_graphics/tally/subtype_tally.cpp b/cockatrice/src/game_graphics/tally/subtype_tally.cpp
index 5d34f6874..2241eb3b2 100644
--- a/cockatrice/src/game_graphics/tally/subtype_tally.cpp
+++ b/cockatrice/src/game_graphics/tally/subtype_tally.cpp
@@ -66,8 +66,9 @@ QList countSubtypes(const QList &cards)
});
// convert entries into TallyRows
- QList rows = QList(entries.size());
- std::transform(entries.begin(), entries.end(), rows.begin(),
+ QList rows;
+ rows.reserve(entries.size());
+ std::transform(entries.begin(), entries.end(), std::back_inserter(rows),
[](const SubtypeEntry &e) { return TallyRow{e.name, QString::number(e.count)}; });
return rows;
diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp
index f9389d0d6..aa2cae024 100644
--- a/cockatrice/src/game_graphics/tally/tally.cpp
+++ b/cockatrice/src/game_graphics/tally/tally.cpp
@@ -1,5 +1,6 @@
#include "tally.h"
+#include "stats_tally.h"
#include "subtype_tally.h"
TallyType Tally::intToType(int value)
@@ -18,6 +19,8 @@ QList Tally::compute(const QList &cards, const TallyType t
return {};
case TallyType::Subtypes:
return SubtypeTally::countSubtypes(cards);
+ case TallyType::TotalPower:
+ return StatsTally::computeTotalPower(cards);
}
return {};
}
diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h
index d0fd77127..97406cddb 100644
--- a/cockatrice/src/game_graphics/tally/tally.h
+++ b/cockatrice/src/game_graphics/tally/tally.h
@@ -20,7 +20,8 @@ enum class TallyType
{
None,
Subtypes,
- MaxValue = Subtypes // sentinel value
+ TotalPower,
+ MaxValue = TotalPower // sentinel value
};
namespace Tally
diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp
index 8d0a28fc6..b52a4955a 100644
--- a/cockatrice/src/game_graphics/zones/hand_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp
@@ -34,7 +34,7 @@ void HandZone::handleDropEvent(const QList &dragItems,
QPoint point = dropPoint + scenePos().toPoint();
int x = -1;
- if (SettingsCache::instance().interface().getHorizontalHand()) {
+ if (SettingsCache::instance().userInterface().getHorizontalHand()) {
for (x = 0; x < getLogic()->getCards().size(); x++) {
if (point.x() < static_cast(getLogic()->getCards().at(x))->scenePos().x()) {
break;
@@ -61,7 +61,7 @@ void HandZone::handleDropEvent(const QList &dragItems,
QRectF HandZone::boundingRect() const
{
- if (SettingsCache::instance().interface().getHorizontalHand()) {
+ if (SettingsCache::instance().userInterface().getHorizontalHand()) {
return QRectF(0, 0, width, CardDimensions::HEIGHT_F + 10);
} else {
return QRectF(0, 0, CardDimensions::WIDTH_F * 1.5, zoneHeight);
@@ -78,8 +78,8 @@ void HandZone::reorganizeCards()
{
if (!getLogic()->getCards().isEmpty()) {
const int cardCount = getLogic()->getCards().size();
- if (SettingsCache::instance().interface().getHorizontalHand()) {
- bool leftJustified = SettingsCache::instance().interface().getLeftJustified();
+ if (SettingsCache::instance().userInterface().getHorizontalHand()) {
+ bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified();
qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width();
const int xPadding = leftJustified ? cardWidth * 1.4 : 5;
qreal totalWidth =
@@ -127,7 +127,7 @@ void HandZone::sortHand(const QList &options)
void HandZone::setWidth(qreal _width)
{
- if (SettingsCache::instance().interface().getHorizontalHand()) {
+ if (SettingsCache::instance().userInterface().getHorizontalHand()) {
prepareGeometryChange();
width = _width;
reorganizeCards();
diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp
index 21138854e..4ef01853f 100644
--- a/cockatrice/src/game_graphics/zones/table_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/table_zone.cpp
@@ -29,7 +29,7 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare
connect(_logic, &TableZoneLogic::contentSizeChanged, this, &TableZone::resizeToContents);
connect(_logic, &TableZoneLogic::toggleTapped, this, &TableZone::toggleTapped);
connect(themeManager, &ThemeManager::themeChanged, this, &TableZone::updateBg);
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::invertVerticalCoordinateChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::invertVerticalCoordinateChanged, this,
&TableZone::reorganizeCards);
updateBg();
@@ -60,8 +60,8 @@ void TableZone::setMirrored(bool isMirrored)
bool TableZone::isInverted() const
{
- return ((mirrored && !SettingsCache::instance().interface().getInvertVerticalCoordinate()) ||
- (!mirrored && SettingsCache::instance().interface().getInvertVerticalCoordinate()));
+ return ((mirrored && !SettingsCache::instance().userInterface().getInvertVerticalCoordinate()) ||
+ (!mirrored && SettingsCache::instance().userInterface().getInvertVerticalCoordinate()));
}
void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
index 90f7dbca5..17118e80d 100644
--- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
+++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp
@@ -66,7 +66,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });
- if (SettingsCache::instance().interface().getFocusCardViewSearchBar()) {
+ if (SettingsCache::instance().userInterface().getFocusCardViewSearchBar()) {
this->setActive(true);
searchEdit.setFocus();
}
@@ -77,9 +77,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
vbox->addItem(searchEditProxy);
// hide search bar if chat autofocus setting is enabled, since typing into it will no longer work anyway
- searchEditProxy->setVisible(!SettingsCache::instance().interface().getKeepGameChatFocus());
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::keepGameChatFocusChanged, searchEditProxy,
- [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); });
+ searchEditProxy->setVisible(!SettingsCache::instance().userInterface().getKeepGameChatFocus());
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged,
+ searchEditProxy, [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); });
// top row
QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal);
@@ -159,9 +159,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
connect(&sortBySelector, static_cast(&QComboBox::currentIndexChanged), this,
&ZoneViewWidget::processSortBy);
connect(&pileViewCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &ZoneViewWidget::processSetPileView);
- groupBySelector.setCurrentIndex(SettingsCache::instance().interface().getZoneViewGroupByIndex());
- sortBySelector.setCurrentIndex(SettingsCache::instance().interface().getZoneViewSortByIndex());
- pileViewCheckBox.setChecked(SettingsCache::instance().interface().getZoneViewPileView());
+ groupBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewGroupByIndex());
+ sortBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewSortByIndex());
+ pileViewCheckBox.setChecked(SettingsCache::instance().userInterface().getZoneViewPileView());
if (CardList::NoSort == static_cast(groupBySelector.currentData().toInt())) {
pileViewCheckBox.setEnabled(false);
@@ -191,7 +191,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
void ZoneViewWidget::processGroupBy(int index)
{
auto option = static_cast(groupBySelector.itemData(index).toInt());
- SettingsCache::instance().interface().setZoneViewGroupByIndex(index);
+ SettingsCache::instance().userInterface().setZoneViewGroupByIndex(index);
zone->setGroupBy(option);
// disable pile view checkbox if we're not grouping by anything
@@ -215,13 +215,13 @@ void ZoneViewWidget::processSortBy(int index)
return;
}
- SettingsCache::instance().interface().setZoneViewSortByIndex(index);
+ SettingsCache::instance().userInterface().setZoneViewSortByIndex(index);
zone->setSortBy(option);
}
void ZoneViewWidget::processSetPileView(QT_STATE_CHANGED_T value)
{
- SettingsCache::instance().interface().setZoneViewPileView(value);
+ SettingsCache::instance().userInterface().setZoneViewPileView(value);
zone->setPileView(value);
}
@@ -478,7 +478,7 @@ static qreal rowsToHeight(int rows)
**/
static qreal calcMaxInitialHeight()
{
- return rowsToHeight(SettingsCache::instance().interface().getCardViewInitialRowsMax());
+ return rowsToHeight(SettingsCache::instance().userInterface().getCardViewInitialRowsMax());
}
/**
@@ -560,7 +560,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const
void ZoneViewWidget::expandWindow()
{
qreal maxInitialHeight = calcMaxInitialHeight();
- qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().interface().getCardViewExpandedRowsMax());
+ qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax());
qreal height = rect().height() - extraHeight - 10;
qreal maxHeight = maximumHeight() - extraHeight - 10;
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
index 47f308698..0a1bb8be1 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
@@ -19,19 +19,22 @@
#include
#include
#include
+#include
#include
-#include
#include
// never cache more than 300 cards at once for a single deck
#define CACHED_CARD_PER_DECK_MAX 300
+// wait at least this long before retrying a card whose picture failed to load
+static constexpr int RETRY_FAILED_CARDS_SECS = 300;
+
CardPictureLoader::CardPictureLoader() : QObject(nullptr)
{
worker = new CardPictureLoaderWorker;
connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this,
&CardPictureLoader::picsPathChanged);
- connect(&SettingsCache::instance().personal(), &PersonalSettings::picDownloadChanged, this,
+ connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this,
&CardPictureLoader::picDownloadChanged);
qRegisterMetaType();
@@ -135,7 +138,14 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize
QPixmap bigPixmap;
if (QPixmapCache::find(key, &bigPixmap)) {
if (bigPixmap.isNull()) {
- qCDebug(CardPictureLoaderLog) << "Cached pixmap for key" << key << "is NULL!";
+ getCardBackLoadingFailedPixmap(pixmap, size);
+ QDateTime failedAtTime = getInstance().failedAt.value(key);
+ if (!failedAtTime.isValid() ||
+ failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) {
+ getInstance().failedAt.remove(key);
+ QPixmapCache::remove(key);
+ getInstance().worker->enqueueImageLoad(card);
+ }
return;
}
@@ -159,8 +169,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
QPixmap finalPixmap;
if (image.isNull()) {
+ getInstance().failedAt.insert(card.getPixmapCacheKey(), QDateTime::currentDateTime());
qCDebug(CardPictureLoaderLog) << "Caching NULL pixmap for" << card.getName();
} else {
+ getInstance().failedAt.remove(card.getPixmapCacheKey());
if (card.getInfo().getUiAttributes().upsideDownArt) {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
QImage mirrorImage = image.flipped(Qt::Horizontal | Qt::Vertical);
@@ -184,8 +196,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
// imageLoaded should only be reached if the exactCard isn't already in cache.
// (plus there's a deduplication mechanism in CardPictureLoaderWorker)
// It should be safe to connect the CardInfo here without worrying about redundant connections.
- connect(card.getCardPtr().data(), &QObject::destroyed, this,
- [cacheKey = card.getPixmapCacheKey()] { QPixmapCache::remove(cacheKey); });
+ connect(card.getCardPtr().data(), &QObject::destroyed, this, [cacheKey = card.getPixmapCacheKey()] {
+ QPixmapCache::remove(cacheKey);
+ getInstance().failedAt.remove(cacheKey);
+ });
card.emitPixmapUpdated();
}
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h
index 0c114ae92..5c3ac84a3 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h
@@ -4,6 +4,8 @@
#include "card_picture_loader_status_bar.h"
#include "card_picture_loader_worker.h"
+#include
+#include
#include
inline Q_LOGGING_CATEGORY(CardPictureLoaderLog, "card_picture_loader");
@@ -56,6 +58,7 @@ private:
CardPictureLoaderWorker *worker; ///< Worker thread for async image loading
CardPictureLoaderStatusBar *statusBar; ///< Status bar widget showing load progress
+ QHash failedAt; ///< Timestamp of the last failed load attempt per pixmap cache key
public:
/**
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp
index 3724c184d..d288236d2 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp
@@ -11,15 +11,17 @@
#include
#include
#include
+#include
#include
-#include
#include
#include
static constexpr int MAX_REQUESTS_PER_SEC = 10;
+static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
+static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
CardPictureLoaderWorker::CardPictureLoaderWorker()
- : QObject(nullptr), picDownload(SettingsCache::instance().personal().getPicDownload()),
+ : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
requestQuota(MAX_REQUESTS_PER_SEC)
{
networkManager = new QNetworkAccessManager(this);
@@ -124,6 +126,19 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
void CardPictureLoaderWorker::resetRequestQuota()
{
requestQuota = MAX_REQUESTS_PER_SEC;
+
+ QDateTime now = QDateTime::currentDateTime();
+ for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
+ if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
+ it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
+ }
+ }
+
+ for (const auto &request : requestLoadQueue) {
+ const QString host = request.first.host();
+ hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
+ }
+
processQueuedRequests();
}
@@ -136,14 +151,26 @@ void CardPictureLoaderWorker::processQueuedRequests()
bool CardPictureLoaderWorker::processSingleRequest()
{
- if (!requestLoadQueue.isEmpty()) {
- auto request = requestLoadQueue.takeFirst();
- makeRequest(request.first, request.second);
- return true;
+ for (int i = 0; i < requestLoadQueue.size(); ++i) {
+ const auto &request = requestLoadQueue.at(i);
+ QString host = request.first.host();
+ int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
+ if (allowance > 0) {
+ hostQuotaRemaining.insert(host, allowance - 1);
+ makeRequest(request.first, request.second);
+ requestLoadQueue.removeAt(i);
+ return true;
+ }
}
return false;
}
+void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
+{
+ hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
+ hostLast429.insert(host, QDateTime::currentDateTime());
+}
+
void CardPictureLoaderWorker::enqueueImageLoad(const ExactCard &card)
{
// Send call through a connection to ensure the handling is run on the pictureLoader thread
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h
index f927abde6..d1c519b7a 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h
@@ -5,6 +5,8 @@
#include "card_picture_loader_worker_work.h"
#include "card_picture_to_load.h"
+#include
+#include
#include
#include
#include
@@ -66,6 +68,12 @@ public:
*/
void queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker);
+ /**
+ * @brief Handles a server returning HTTP 429 by reducing that host's request quota.
+ * @param host The host that returned 429
+ */
+ void onHostRateLimited(const QString &host);
+
/** @brief Clears the network cache and redirect cache. */
void clearNetworkCache();
@@ -110,8 +118,11 @@ private:
bool picDownload; ///< Whether downloading images from network is enabled
QQueue> requestLoadQueue; ///< Queue of pending network requests
- int requestQuota; ///< Remaining requests allowed per second
- QTimer requestTimer; ///< Timer to reset the request quota
+ int requestQuota; ///< Remaining requests allowed per second
+ QTimer requestTimer; ///< Timer to reset the request quota
+ QHash hostRequestQuota; ///< Sustained per-host request allowance
+ QHash hostQuotaRemaining; ///< Per-host allowance left in the current second
+ QHash hostLast429; ///< When each host was last rate limited
CardPictureLoaderLocal *localLoader; ///< Loader for local images
QSet currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
index ebaf11fff..66c56337c 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
@@ -8,9 +8,13 @@
#include
#include
#include
+#include
#include
#include
-#include
+#include
+
+ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter;
+#include
// Card back returned by gatherer when card is not found
static const QStringList MD5_BLACKLIST = {
@@ -20,7 +24,7 @@ static const QStringList MD5_BLACKLIST = {
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
- picDownload(SettingsCache::instance().personal().getPicDownload())
+ picDownload(SettingsCache::instance().downloads().getPicDownload())
{
// Hook up signals to the orchestrator
connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest);
@@ -30,19 +34,48 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoader
connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded);
connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker,
&CardPictureLoaderWorker::imageRequestSucceeded);
+ connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited);
// Hook up signals to settings
- connect(&SettingsCache::instance().personal(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged()));
+ connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged()));
startNextPicDownload();
}
void CardPictureLoaderWorkerWork::startNextPicDownload()
{
+ QDateTime now = QDateTime::currentDateTime();
+ while (!cardToDownload.getCurrentUrl().isEmpty() &&
+ s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) {
+ QString host = QUrl(cardToDownload.getCurrentUrl()).host();
+ if (s_rateLimiter.rounds(host) == 1) {
+ // First 429 round for this server: wait out the backoff and give it
+ // one more chance instead of immediately falling through to a worse
+ // source. A second 429 makes us fall through instead.
+ qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
+ << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
+ << " set: " << cardToDownload.getSetName() << "]: Waiting out backoff for " << host << " to retry "
+ << cardToDownload.getCurrentUrl();
+ scheduleDeferredRetry();
+ return;
+ }
+
+ // The server has already 429'd us at least twice, so further retries are
+ // unlikely to succeed: move on to the other configured sources.
+ qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
+ << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
+ << " set: " << cardToDownload.getSetName() << "]: Skipping rate-limited URL "
+ << cardToDownload.getCurrentUrl() << " (server " << host << " still rate limiting)";
+ if (!cardToDownload.nextUrl() && !cardToDownload.nextSet()) {
+ scheduleDeferredRetry();
+ return;
+ }
+ }
+
QString picUrl = cardToDownload.getCurrentUrl();
if (picUrl.isEmpty()) {
- picDownloadFailed();
+ scheduleDeferredRetry();
} else {
QUrl url(picUrl);
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
@@ -108,7 +141,41 @@ static bool imageIsBlackListed(const QByteArray &picData)
void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply)
{
if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 429) {
- qCWarning(CardPictureLoaderWorkerWorkLog) << "Too many requests.";
+ QString host = reply->url().host();
+ QDateTime now = QDateTime::currentDateTime();
+
+ qint64 retryAfterMs = 0;
+ const QByteArray retryAfterHeader = reply->rawHeader("Retry-After");
+ if (!retryAfterHeader.isEmpty()) {
+ bool ok = false;
+ int seconds = retryAfterHeader.toInt(&ok);
+ if (ok && seconds > 0) {
+ retryAfterMs = static_cast(seconds) * 1000;
+ } else {
+ QDateTime retryAfterDate =
+ QDateTime::fromString(QString::fromLatin1(retryAfterHeader), Qt::RFC2822Date);
+ if (retryAfterDate.isValid()) {
+ retryAfterMs = qMax(0, now.msecsTo(retryAfterDate));
+ }
+ }
+ }
+
+ QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs);
+ emit rateLimited(host);
+
+ if (s_rateLimiter.rounds(host) == 1) {
+ qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
+ << "PictureLoader: [card: " << cardToDownload.getCard().getName()
+ << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
+ << ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url";
+ scheduleDeferredRetry();
+ } else {
+ qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
+ << "PictureLoader: [card: " << cardToDownload.getCard().getName()
+ << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
+ << ", retry already attempted, falling through to other sources";
+ picDownloadFailed();
+ }
} else {
bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool();
@@ -149,6 +216,9 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply)
return;
}
+ // A non-redirect successful response means the server is not rate limiting us anymore.
+ s_rateLimiter.onSuccess(reply->url().host());
+
// peek is used to keep the data in the buffer for use by QImageReader
const QByteArray &picData = reply->peek(reply->size());
@@ -203,6 +273,42 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply)
return imgReader.read();
}
+void CardPictureLoaderWorkerWork::scheduleDeferredRetry()
+{
+ QDateTime now = QDateTime::currentDateTime();
+
+ // Prefer waiting on the current URL's server so we retry the same source.
+ QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host();
+ QDateTime backoffUntil = s_rateLimiter.deadline(currentHost);
+ if (!s_rateLimiter.isRateLimited(currentHost, now)) {
+ backoffUntil = s_rateLimiter.earliestDeadline(now);
+ }
+
+ if (!backoffUntil.isValid()) {
+ qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
+ << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
+ << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, no servers in backoff: BAILING OUT";
+ concludeImageLoad(QImage());
+ return;
+ }
+
+ qint64 waitMs = qMax(0, now.msecsTo(backoffUntil));
+ // Add some jitter to desynchronize concurrent retries and avoid a thundering herd.
+ waitMs += QRandomGenerator::global()->bounded(5000);
+
+ qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
+ << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName()
+ << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs
+ << "ms";
+
+ QTimer::singleShot(waitMs, this, [this] {
+ s_rateLimiter.clearExpired(QDateTime::currentDateTime());
+
+ cardToDownload.resetIndices();
+ startNextPicDownload();
+ });
+}
+
void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image)
{
emit imageLoaded(cardToDownload.getCard(), image);
@@ -211,5 +317,5 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image)
void CardPictureLoaderWorkerWork::picDownloadChanged()
{
- picDownload = SettingsCache::instance().personal().getPicDownload();
+ picDownload = SettingsCache::instance().downloads().getPicDownload();
}
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h
index cdffc1dff..1e56a4373 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h
@@ -4,12 +4,15 @@
#include "card_picture_loader_worker.h"
#include "card_picture_to_load.h"
+#include
#include
#include
#include
#include
+#include
#include
#include
+#include
inline Q_LOGGING_CATEGORY(CardPictureLoaderWorkerWorkLog, "card_picture_loader.worker");
@@ -50,6 +53,8 @@ public slots:
private:
bool picDownload; ///< Whether network downloading is enabled
+ static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state
+
/** @brief Starts downloading the next URL for this card. */
void startNextPicDownload();
@@ -77,6 +82,16 @@ private:
*/
void concludeImageLoad(const QImage &image);
+ /**
+ * @brief Schedules a deferred retry after the relevant server backoff expires.
+ *
+ * Waits on the current URL's server when it is the reason we are blocked,
+ * otherwise on the earliest active backoff. If no servers are in backoff,
+ * concludes with failure. Otherwise resets the CardPictureToLoad indices and
+ * retries after the backoff period.
+ */
+ void scheduleDeferredRetry();
+
private slots:
/** @brief Updates the picDownload setting when it changes. */
void picDownloadChanged();
@@ -100,6 +115,9 @@ signals:
/** @brief Emitted when a URL has been redirected. */
void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl);
+ /** @brief Emitted when a server returned HTTP 429. */
+ void rateLimited(const QString &host);
+
/** @brief Emitted when a cached URL is invalid and must be removed. */
void cachedUrlInvalidated(const QUrl &url);
};
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
index 33e4aabdb..5f4ff0bbd 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
@@ -17,8 +17,9 @@ CardPictureToLoad::CardPictureToLoad(const ExactCard &_card)
{
if (card) {
sortedSets = extractSetsSorted(card);
- // The first time called, nextSet will also populate the Urls for the first set.
- nextSet();
+ currentSetIndex = 0;
+ currentSet = sortedSets.first();
+ populateSetUrls();
}
}
@@ -101,15 +102,19 @@ void CardPictureToLoad::populateSetUrls()
}
}
- /* Call nextUrl to make sure currentUrl is up-to-date
- but we don't need the result here. */
- (void)nextUrl();
+ currentUrlIndex = 0;
+ if (!currentSetUrls.isEmpty()) {
+ currentUrl = currentSetUrls.first();
+ } else {
+ currentUrl = QString();
+ }
}
bool CardPictureToLoad::nextSet()
{
- if (!sortedSets.isEmpty()) {
- currentSet = sortedSets.takeFirst();
+ currentSetIndex++;
+ if (currentSetIndex < sortedSets.size()) {
+ currentSet = sortedSets.at(currentSetIndex);
populateSetUrls();
return true;
}
@@ -119,8 +124,9 @@ bool CardPictureToLoad::nextSet()
bool CardPictureToLoad::nextUrl()
{
- if (!currentSetUrls.isEmpty()) {
- currentUrl = currentSetUrls.takeFirst();
+ currentUrlIndex++;
+ if (currentUrlIndex < currentSetUrls.size()) {
+ currentUrl = currentSetUrls.at(currentUrlIndex);
return true;
}
currentUrl = QString();
@@ -136,6 +142,28 @@ QString CardPictureToLoad::getSetName() const
}
}
+QString CardPictureToLoad::peekNextUrl() const
+{
+ int nextIndex = currentUrlIndex + 1;
+ if (nextIndex < currentSetUrls.size()) {
+ return currentSetUrls.at(nextIndex);
+ }
+ return QString();
+}
+
+void CardPictureToLoad::resetIndices()
+{
+ currentSetIndex = 0;
+ if (!sortedSets.isEmpty()) {
+ currentSet = sortedSets.first();
+ populateSetUrls();
+ } else {
+ currentSet = {};
+ currentSetUrls.clear();
+ currentUrl = QString();
+ }
+}
+
static int parse(const QString &urlTemplate,
const QString &propType,
const QString &cardName,
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h
index b57e57644..9e0e7449c 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h
@@ -25,6 +25,8 @@ private:
QList currentSetUrls; ///< URLs for the current set being attempted
QString currentUrl; ///< Currently active URL to download
CardSetPtr currentSet; ///< Currently active set
+ int currentSetIndex = 0; ///< Current position in sortedSets
+ int currentUrlIndex = 0; ///< Current position in currentSetUrls
public:
/**
@@ -56,6 +58,9 @@ public:
/** @return The short name of the current set, or empty string if no set. */
[[nodiscard]] QString getSetName() const;
+ /** @return The next URL in the current set's list without advancing, or empty if at end. */
+ [[nodiscard]] QString peekNextUrl() const;
+
/**
* @brief Transforms a URL template into a concrete URL for this card/set.
* @param urlTemplate The URL template to transform
@@ -88,6 +93,14 @@ public:
*/
void populateSetUrls();
+ /**
+ * @brief Resets iteration indices to the beginning.
+ *
+ * Restarts URL/set iteration from the first set and first URL.
+ * Used for deferred retry after server backoff expires.
+ */
+ void resetIndices();
+
/**
* @brief Extract all sets from the card and sort them by priority.
* @param card The card to extract sets from
diff --git a/cockatrice/src/interface/intents/contexts/context_connect_to_server.h b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h
new file mode 100644
index 000000000..c7c40b261
--- /dev/null
+++ b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h
@@ -0,0 +1,14 @@
+#ifndef COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
+#define COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
+
+#include
+
+struct ContextConnectToServer
+{
+ QString hostname;
+ QString port;
+ QString username;
+ QString password;
+};
+
+#endif // COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H
diff --git a/cockatrice/src/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h
new file mode 100644
index 000000000..102e2a520
--- /dev/null
+++ b/cockatrice/src/interface/intents/contexts/context_join_game.h
@@ -0,0 +1,11 @@
+#ifndef COCKATRICE_CONTEXT_JOIN_GAME_H
+#define COCKATRICE_CONTEXT_JOIN_GAME_H
+#include "context_join_room.h"
+
+struct ContextJoinGame
+{
+ ContextJoinRoom roomContext;
+ int gameId;
+};
+
+#endif // COCKATRICE_CONTEXT_JOIN_GAME_H
diff --git a/cockatrice/src/interface/intents/contexts/context_join_room.h b/cockatrice/src/interface/intents/contexts/context_join_room.h
new file mode 100644
index 000000000..23ae05e81
--- /dev/null
+++ b/cockatrice/src/interface/intents/contexts/context_join_room.h
@@ -0,0 +1,14 @@
+#ifndef COCKATRICE_CONTEXT_JOIN_ROOM_H
+#define COCKATRICE_CONTEXT_JOIN_ROOM_H
+
+#include "context_connect_to_server.h"
+
+#include
+
+struct ContextJoinRoom
+{
+ ContextConnectToServer serverContext;
+ int roomId;
+};
+
+#endif // COCKATRICE_CONTEXT_JOIN_ROOM_H
diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp
new file mode 100644
index 000000000..c02a89f35
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent.cpp
@@ -0,0 +1,48 @@
+#include "intent.h"
+
+Intent::Intent(QObject *parent) : QObject(parent)
+{
+ // An intent is done as soon as it reports success or failure. Deleting it
+ // also tears down its dependency chain and disconnects any signal wiring.
+ connect(this, &Intent::finished, this, &QObject::deleteLater);
+ connect(this, &Intent::failed, this, &QObject::deleteLater);
+}
+
+Intent::~Intent() = default;
+
+void Intent::execute()
+{
+ if (checkPrecondition()) {
+ onPreconditionSatisfied();
+ } else {
+ onPreconditionNotSatisfied();
+ }
+}
+
+void Intent::runDependency(Intent *dependency)
+{
+ dependency->setParent(this);
+ connect(dependency, &Intent::finished, this, [this]() {
+ // Re-check after dependency finishes
+ this->execute();
+ });
+ connect(dependency, &Intent::failed, this, &Intent::failed);
+
+ dependency->execute();
+}
+
+void Intent::emitFinished()
+{
+ if (!completed) {
+ completed = true;
+ emit finished();
+ }
+}
+
+void Intent::emitFailed(const QString &reason)
+{
+ if (!completed) {
+ completed = true;
+ emit failed(reason);
+ }
+}
diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h
new file mode 100644
index 000000000..125900ecd
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent.h
@@ -0,0 +1,37 @@
+#ifndef COCKATRICE_INTENT_H
+#define COCKATRICE_INTENT_H
+
+#include
+
+class Intent : public QObject
+{
+ Q_OBJECT
+
+public:
+ explicit Intent(QObject *parent = nullptr);
+ ~Intent() override;
+
+ void execute();
+
+signals:
+ void finished();
+ void failed(QString reason);
+
+protected:
+ // --- Subclasses must implement these ---
+ virtual bool checkPrecondition() const = 0;
+ virtual void onPreconditionSatisfied() = 0;
+ virtual void onPreconditionNotSatisfied() = 0;
+
+ // Helper to chain another intent
+ void runDependency(Intent *dependency);
+
+ // Emit the outcome exactly once; ignore late signals after the intent is done.
+ void emitFinished();
+ void emitFailed(const QString &reason);
+
+private:
+ bool completed = false;
+};
+
+#endif // COCKATRICE_INTENT_H
diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.cpp b/cockatrice/src/interface/intents/intent_connect_to_server.cpp
new file mode 100644
index 000000000..1cccc5a23
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_connect_to_server.cpp
@@ -0,0 +1,46 @@
+#include "intent_connect_to_server.h"
+
+#include "intent_disconnect_from_server.h"
+
+#include
+
+IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context)
+ : Intent(), remoteClient(_remoteClient), context(_context)
+{
+}
+
+bool IntentConnectToServer::checkPrecondition() const
+{
+ return remoteClient->getStatus() == ClientStatus::StatusDisconnected;
+}
+
+void IntentConnectToServer::onPreconditionSatisfied()
+{
+ remoteClient->connectToServer(context->hostname, context->port.toUInt(), context->username, context->password);
+ connect(remoteClient, &RemoteClient::statusChanged, this, &IntentConnectToServer::onStatusChanged);
+ connect(remoteClient, &RemoteClient::socketError, this, &IntentConnectToServer::onSocketError);
+ connect(
+ remoteClient, &RemoteClient::loginError, this,
+ [this](Response::ResponseCode, const QString &reason, quint32, const QList &) { emitFailed(reason); });
+
+ QTimer::singleShot(15000, this, [this]() {
+ emitFailed(tr("Timed out while connecting to %1:%2").arg(context->hostname, context->port));
+ });
+}
+
+void IntentConnectToServer::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentDisconnectFromServer(remoteClient));
+}
+
+void IntentConnectToServer::onStatusChanged(ClientStatus status)
+{
+ if (status == ClientStatus::StatusLoggedIn) {
+ emitFinished();
+ }
+}
+
+void IntentConnectToServer::onSocketError(const QString &errorString)
+{
+ emitFailed(tr("Failed to connect to %1:%2: %3").arg(context->hostname, context->port, errorString));
+}
diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.h b/cockatrice/src/interface/intents/intent_connect_to_server.h
new file mode 100644
index 000000000..eab4d1a21
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_connect_to_server.h
@@ -0,0 +1,29 @@
+#ifndef COCKATRICE_INTENT_CONNECT_TO_SERVER_H
+#define COCKATRICE_INTENT_CONNECT_TO_SERVER_H
+
+#include "contexts/context_connect_to_server.h"
+#include "intent.h"
+#include "remote_client.h"
+
+class IntentConnectToServer : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ RemoteClient *remoteClient;
+ ContextConnectToServer *context;
+
+private slots:
+ void onStatusChanged(ClientStatus status);
+ void onSocketError(const QString &errorString);
+};
+
+#endif // COCKATRICE_INTENT_CONNECT_TO_SERVER_H
diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp
new file mode 100644
index 000000000..cb39d7bab
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp
@@ -0,0 +1,29 @@
+#include "intent_disconnect_from_server.h"
+
+IntentDisconnectFromServer::IntentDisconnectFromServer(RemoteClient *_remoteClient)
+ : Intent(), remoteClient(_remoteClient)
+{
+}
+
+bool IntentDisconnectFromServer::checkPrecondition() const
+{
+ return remoteClient->getStatus() == ClientStatus::StatusDisconnected;
+}
+
+void IntentDisconnectFromServer::onPreconditionSatisfied()
+{
+ emitFinished();
+}
+
+void IntentDisconnectFromServer::onPreconditionNotSatisfied()
+{
+ connect(remoteClient, &RemoteClient::statusChanged, this, &IntentDisconnectFromServer::onStatusChanged);
+ remoteClient->disconnectFromServer();
+}
+
+void IntentDisconnectFromServer::onStatusChanged(ClientStatus status)
+{
+ if (status == ClientStatus::StatusDisconnected) {
+ emitFinished();
+ }
+}
diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.h b/cockatrice/src/interface/intents/intent_disconnect_from_server.h
new file mode 100644
index 000000000..6e1dfd0c1
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.h
@@ -0,0 +1,26 @@
+#ifndef COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H
+#define COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H
+
+#include "intent.h"
+#include "remote_client.h"
+
+class IntentDisconnectFromServer : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentDisconnectFromServer(RemoteClient *_remoteClient);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ RemoteClient *remoteClient;
+
+private slots:
+ void onStatusChanged(ClientStatus status);
+};
+
+#endif // COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H
diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp
new file mode 100644
index 000000000..fb9c4d5ce
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp
@@ -0,0 +1,76 @@
+#include "intent_join_server_game.h"
+
+#include "../widgets/server/game_selector.h"
+#include "../widgets/tabs/tab_room.h"
+#include "../widgets/tabs/tab_supervisor.h"
+#include "intent_join_server_room.h"
+
+#include
+
+IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor,
+ RemoteClient *_remoteClient,
+ std::unique_ptr _context)
+ : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release())
+{
+}
+
+bool IntentJoinServerGame::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->roomContext.serverContext.hostname) {
+ return false;
+ }
+ if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) {
+ return false;
+ }
+
+ if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) {
+ return false;
+ }
+
+ return true;
+}
+
+void IntentJoinServerGame::onPreconditionSatisfied()
+{
+ TabRoom *room = tabSupervisor->getRoomTabs().value(context->roomContext.roomId);
+ if (!tryJoinGame(room)) {
+ waitForGame(room);
+ }
+}
+
+void IntentJoinServerGame::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentJoinServerRoom(tabSupervisor, remoteClient, &context->roomContext));
+}
+
+bool IntentJoinServerGame::tryJoinGame(TabRoom *room)
+{
+ if (!room) {
+ return false;
+ }
+
+ if (room->getGameSelector()->joinGameById(context->gameId)) {
+ emitFinished();
+ return true;
+ }
+
+ return false;
+}
+
+void IntentJoinServerGame::waitForGame(TabRoom *room)
+{
+ connect(room, &TabRoom::gameListUpdated, this, [this]() {
+ TabRoom *updatedRoom = tabSupervisor->getRoomTabs().value(context->roomContext.roomId);
+ if (updatedRoom) {
+ tryJoinGame(updatedRoom);
+ }
+ });
+
+ QTimer::singleShot(15000, this, [this]() { emitFailed(tr("Game %1 not found in the room").arg(context->gameId)); });
+}
diff --git a/cockatrice/src/interface/intents/intent_join_server_game.h b/cockatrice/src/interface/intents/intent_join_server_game.h
new file mode 100644
index 000000000..5e196df38
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_game.h
@@ -0,0 +1,37 @@
+#ifndef COCKATRICE_INTENT_JOIN_SERVER_GAME_H
+#define COCKATRICE_INTENT_JOIN_SERVER_GAME_H
+
+#include "contexts/context_join_game.h"
+#include "intent.h"
+#include "remote_client.h"
+
+#include
+#include
+
+class TabRoom;
+class TabSupervisor;
+
+class IntentJoinServerGame : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentJoinServerGame(TabSupervisor *_tabSupervisor,
+ RemoteClient *_remoteClient,
+ std::unique_ptr _context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ bool tryJoinGame(TabRoom *room);
+ void waitForGame(TabRoom *room);
+
+ TabSupervisor *tabSupervisor;
+ RemoteClient *remoteClient;
+ QScopedPointer context;
+};
+
+#endif // COCKATRICE_INTENT_JOIN_SERVER_GAME_H
diff --git a/cockatrice/src/interface/intents/intent_join_server_room.cpp b/cockatrice/src/interface/intents/intent_join_server_room.cpp
new file mode 100644
index 000000000..d25bc8d17
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_room.cpp
@@ -0,0 +1,74 @@
+#include "intent_join_server_room.h"
+
+#include "../widgets/tabs/tab_room.h"
+#include "../widgets/tabs/tab_server.h"
+#include "../widgets/tabs/tab_supervisor.h"
+#include "intent_connect_to_server.h"
+
+#include
+#include
+
+IntentJoinServerRoom::IntentJoinServerRoom(TabSupervisor *_tabSupervisor,
+ RemoteClient *_remoteClient,
+ ContextJoinRoom *_context)
+ : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context)
+{
+}
+
+bool IntentJoinServerRoom::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 IntentJoinServerRoom::onPreconditionSatisfied()
+{
+ if (tabSupervisor->getRoomTabs().contains(context->roomId)) {
+ tabSupervisor->setCurrentWidget(tabSupervisor->getRoomTabs().value(context->roomId));
+ emitFinished();
+ return;
+ }
+
+ TabServer *tabServer = tabSupervisor->getTabServer();
+ if (!tabServer) {
+ tabSupervisor->openTabServer();
+ tabServer = tabSupervisor->getTabServer();
+ }
+ if (!tabServer) {
+ emitFailed(tr("No server tab available"));
+ return;
+ }
+
+ const int roomId = context->roomId;
+ tabServer->joinRoom(roomId, true);
+ connect(tabServer, &TabServer::roomJoined, this, [this, roomId](const ServerInfo_Room &info, bool) {
+ if (info.room_id() == roomId) {
+ emitFinished();
+ }
+ });
+ connect(tabServer, &TabServer::roomJoinFailed, this, [this, roomId](int failedRoomId) {
+ if (failedRoomId == roomId) {
+ emitFailed(tr("Failed to join the server room %1").arg(roomId));
+ }
+ });
+
+ QTimer::singleShot(15000, this,
+ [this, roomId]() { emitFailed(tr("Timed out while joining the server room %1").arg(roomId)); });
+}
+
+void IntentJoinServerRoom::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentConnectToServer(remoteClient, &context->serverContext));
+}
diff --git a/cockatrice/src/interface/intents/intent_join_server_room.h b/cockatrice/src/interface/intents/intent_join_server_room.h
new file mode 100644
index 000000000..4a5599896
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_join_server_room.h
@@ -0,0 +1,28 @@
+#ifndef COCKATRICE_INTENT_JOIN_SERVER_ROOM_H
+#define COCKATRICE_INTENT_JOIN_SERVER_ROOM_H
+
+#include "contexts/context_join_room.h"
+#include "intent.h"
+#include "remote_client.h"
+
+class TabSupervisor;
+
+class IntentJoinServerRoom : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentJoinServerRoom(TabSupervisor *_tabSupervisor, RemoteClient *_remoteClient, ContextJoinRoom *_context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ TabSupervisor *tabSupervisor;
+ RemoteClient *remoteClient;
+ ContextJoinRoom *context;
+};
+
+#endif // COCKATRICE_INTENT_JOIN_SERVER_ROOM_H
diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp
new file mode 100644
index 000000000..ff871fd03
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_login.cpp
@@ -0,0 +1,33 @@
+#include "intent_login.h"
+
+#include "../../client/settings/cache_settings.h"
+#include "libcockatrice/settings/servers_settings.h"
+
+IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context)
+{
+}
+
+bool IntentGetLoginCredentials::checkPrecondition() const
+{
+ ServersSettings &servers = SettingsCache::instance().servers();
+ return servers.hasLoginData(context->hostname, context->port);
+}
+
+void IntentGetLoginCredentials::onPreconditionSatisfied()
+{
+ ServersSettings &servers = SettingsCache::instance().servers();
+ const int index = servers.findServerIndex(context->hostname, context->port);
+
+ if (index >= 0) {
+ context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
+ context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
+ emitFinished();
+ } else {
+ emitFailed(tr("No saved credentials for this server"));
+ }
+}
+
+void IntentGetLoginCredentials::onPreconditionNotSatisfied()
+{
+ emitFailed(tr("No saved credentials for this server"));
+}
diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h
new file mode 100644
index 000000000..c7fec92b7
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_login.h
@@ -0,0 +1,23 @@
+#ifndef COCKATRICE_INTENT_LOGIN_H
+#define COCKATRICE_INTENT_LOGIN_H
+
+#include "contexts/context_connect_to_server.h"
+#include "intent.h"
+
+class IntentGetLoginCredentials : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentGetLoginCredentials(ContextConnectToServer *_context);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ ContextConnectToServer *context;
+};
+
+#endif // COCKATRICE_INTENT_LOGIN_H
diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.cpp b/cockatrice/src/interface/intents/intent_open_local_deck.cpp
new file mode 100644
index 000000000..2457bec72
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp
@@ -0,0 +1,34 @@
+#include "intent_open_local_deck.h"
+
+#include "../deck_loader/deck_file_format.h"
+#include "../deck_loader/deck_loader.h"
+#include "../widgets/tabs/tab_supervisor.h"
+#include "intent_wait_for_database_load.h"
+
+#include
+
+IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file)
+ : Intent(), tabSupervisor(_tabSupervisor), file(_file)
+{
+}
+
+bool IntentOpenLocalDeck::checkPrecondition() const
+{
+ return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok;
+}
+
+void IntentOpenLocalDeck::onPreconditionSatisfied()
+{
+ std::optional deckOpt = DeckLoader::loadFromFile(file, DeckFileFormat::getFormatFromName(file), true);
+ if (deckOpt) {
+ tabSupervisor->openDeckInNewTab(deckOpt.value());
+ emitFinished();
+ } else {
+ emitFailed(tr("Unable to load deck file %1").arg(file));
+ }
+}
+
+void IntentOpenLocalDeck::onPreconditionNotSatisfied()
+{
+ runDependency(new IntentWaitForDatabaseLoad);
+}
diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.h b/cockatrice/src/interface/intents/intent_open_local_deck.h
new file mode 100644
index 000000000..97f875e39
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_open_local_deck.h
@@ -0,0 +1,27 @@
+#ifndef COCKATRICE_INTENT_OPEN_LOCAL_DECK_H
+#define COCKATRICE_INTENT_OPEN_LOCAL_DECK_H
+
+#include "intent.h"
+
+#include
+
+class TabSupervisor;
+
+class IntentOpenLocalDeck : public Intent
+{
+ Q_OBJECT
+
+public:
+ IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file);
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+
+private:
+ TabSupervisor *tabSupervisor;
+ QString file;
+};
+
+#endif // COCKATRICE_INTENT_OPEN_LOCAL_DECK_H
diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp
new file mode 100644
index 000000000..c36378818
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp
@@ -0,0 +1,19 @@
+#include "intent_wait_for_database_load.h"
+
+#include
+
+bool IntentWaitForDatabaseLoad::checkPrecondition() const
+{
+ return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok;
+}
+
+void IntentWaitForDatabaseLoad::onPreconditionSatisfied()
+{
+ emitFinished();
+}
+
+void IntentWaitForDatabaseLoad::onPreconditionNotSatisfied()
+{
+ connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this,
+ [this]() { emitFinished(); });
+}
diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.h b/cockatrice/src/interface/intents/intent_wait_for_database_load.h
new file mode 100644
index 000000000..72f4a1ffc
--- /dev/null
+++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.h
@@ -0,0 +1,16 @@
+#ifndef COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H
+#define COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H
+
+#include "intent.h"
+
+class IntentWaitForDatabaseLoad : public Intent
+{
+ Q_OBJECT
+
+protected:
+ bool checkPrecondition() const override;
+ void onPreconditionSatisfied() override;
+ void onPreconditionNotSatisfied() override;
+};
+
+#endif // COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H
diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp
new file mode 100644
index 000000000..8b5309603
--- /dev/null
+++ b/cockatrice/src/interface/intents/url_parser.cpp
@@ -0,0 +1,89 @@
+#include "url_parser.h"
+
+#include "../window_main.h"
+#include "contexts/context_join_game.h"
+#include "intent_join_server_game.h"
+#include "intent_login.h"
+
+#include
+#include
+#include
+#include
+#include
+
+IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
+{
+}
+
+void IntentUrlParser::handle(const QString &urlStr)
+{
+ QUrl url(urlStr);
+
+ if (url.scheme() != "cockatrice") {
+ return;
+ }
+
+ const QString action = url.host();
+ QUrlQuery query(url);
+
+ if (action == "joingame") {
+ handleJoinGame(query);
+ } else if (action == "opendeck") {
+ // handleOpenDeck(query);
+ } else {
+ qWarning() << "Unknown intent:" << action;
+ }
+}
+
+void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
+{
+ auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); };
+
+ auto ctx = std::make_unique();
+
+ ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname");
+ ctx->roomContext.serverContext.port = query.queryItemValue("port");
+
+ if (ctx->roomContext.serverContext.hostname.isEmpty()) {
+ showError(tr("Missing or empty hostname in the game link"));
+ return;
+ }
+
+ bool ok = false;
+ ctx->roomContext.serverContext.port.toUShort(&ok);
+ if (!ok) {
+ showError(tr("Invalid or missing port in the game link"));
+ return;
+ }
+
+ ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
+
+ if (!ok) {
+ showError(tr("Invalid or missing room id in the game link"));
+ return;
+ }
+
+ ok = false;
+ ctx->gameId = query.queryItemValue("gameid").toInt(&ok);
+
+ if (!ok) {
+ showError(tr("Invalid or missing game id in the game link"));
+ return;
+ }
+
+ // The join game intent owns the context and the credential lookup; once the
+ // chain finishes (or fails) it deletes the whole tree.
+ ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
+ auto joinGameIntent =
+ new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
+ joinGameIntent->setParent(this);
+
+ auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
+ getLoginCredentialsIntent->setParent(joinGameIntent);
+
+ connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
+ connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
+ connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
+
+ getLoginCredentialsIntent->execute();
+}
diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h
new file mode 100644
index 000000000..bac0e3d25
--- /dev/null
+++ b/cockatrice/src/interface/intents/url_parser.h
@@ -0,0 +1,20 @@
+#ifndef COCKATRICE_URL_PARSER_H
+#define COCKATRICE_URL_PARSER_H
+#include
+#include
+
+class MainWindow;
+class IntentUrlParser : public QObject
+{
+ Q_OBJECT
+
+public:
+ IntentUrlParser(QObject *parent, MainWindow *mainWindow);
+ void handle(const QString &urlStr);
+ void handleJoinGame(const QUrlQuery &query);
+
+private:
+ MainWindow *mainWindow;
+};
+
+#endif // COCKATRICE_URL_PARSER_H
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
index 9c52f535f..fc53b296f 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
@@ -2,6 +2,8 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
+#include "../settings_page/user_interface_settings_page.h"
+#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
#include "deck_state_manager.h"
@@ -12,7 +14,7 @@
#include
#include
#include
-#include
+#include
#include
#include
@@ -111,20 +113,18 @@ void DeckEditorDeckDockWidget::createDeckDock()
showBannerCardCheckBox = new QCheckBox();
showBannerCardCheckBox->setObjectName("showBannerCardCheckBox");
- showBannerCardCheckBox->setChecked(
- SettingsCache::instance().cardsDisplay().getDeckEditorBannerCardComboBoxVisible());
- connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
- &CardsDisplaySettings::setDeckEditorBannerCardComboBoxVisible);
- connect(&SettingsCache::instance().cardsDisplay(),
- &CardsDisplaySettings::deckEditorBannerCardComboBoxVisibleChanged, this,
+ showBannerCardCheckBox->setChecked(SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible());
+ connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(),
+ &DeckEditorSettings::setBannerCardComboBoxVisible);
+ connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::bannerCardComboBoxVisibleChanged, this,
&DeckEditorDeckDockWidget::updateShowBannerCardComboBox);
showTagsWidgetCheckBox = new QCheckBox();
showTagsWidgetCheckBox->setObjectName("showTagsWidgetCheckBox");
- showTagsWidgetCheckBox->setChecked(SettingsCache::instance().cardsDisplay().getDeckEditorTagsWidgetVisible());
- connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
- &CardsDisplaySettings::setDeckEditorTagsWidgetVisible);
- connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::deckEditorTagsWidgetVisibleChanged, this,
+ showTagsWidgetCheckBox->setChecked(SettingsCache::instance().deckEditor().getTagsWidgetVisible());
+ connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(),
+ &DeckEditorSettings::setTagsWidgetVisible);
+ connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::tagsWidgetVisibleChanged, this,
&DeckEditorDeckDockWidget::updateShowTagsWidget);
quickSettingsWidget->addSettingsWidget(showBannerCardCheckBox);
@@ -136,6 +136,8 @@ void DeckEditorDeckDockWidget::createDeckDock()
formatComboBox->addItem(tr("Loading Database..."));
formatComboBox->setEnabled(false); // Disable until loaded
+ commanderBracketWidget = new CommanderBracketWidget(this);
+
commentsLabel = new QLabel();
commentsLabel->setObjectName("commentsLabel");
commentsEdit = new QTextEdit;
@@ -156,7 +158,7 @@ void DeckEditorDeckDockWidget::createDeckDock()
bannerCardLabel = new QLabel();
bannerCardLabel->setObjectName("bannerCardLabel");
bannerCardLabel->setText(tr("Banner Card"));
- bannerCardLabel->setHidden(!SettingsCache::instance().cardsDisplay().getDeckEditorBannerCardComboBoxVisible());
+ bannerCardLabel->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible());
bannerCardComboBox = new QComboBox(this);
connect(getModel(), &DeckListModel::cardNodesChanged, this, [this]() {
// Delay the update to avoid race conditions
@@ -167,10 +169,10 @@ void DeckEditorDeckDockWidget::createDeckDock()
connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this,
&DeckEditorDeckDockWidget::writeBannerCard);
- bannerCardComboBox->setHidden(!SettingsCache::instance().cardsDisplay().getDeckEditorBannerCardComboBoxVisible());
+ bannerCardComboBox->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible());
deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this, {});
- deckTagsDisplayWidget->setHidden(!SettingsCache::instance().cardsDisplay().getDeckEditorTagsWidgetVisible());
+ deckTagsDisplayWidget->setHidden(!SettingsCache::instance().deckEditor().getTagsWidgetVisible());
connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, deckStateManager,
&DeckStateManager::setTags);
@@ -221,13 +223,15 @@ void DeckEditorDeckDockWidget::createDeckDock()
upperLayout->addWidget(formatLabel, 2, 0);
upperLayout->addWidget(formatComboBox, 2, 1);
- upperLayout->addWidget(bannerCardLabel, 3, 0);
- upperLayout->addWidget(bannerCardComboBox, 3, 1);
+ upperLayout->addWidget(commanderBracketWidget, 3, 0, 1, 2);
- upperLayout->addWidget(deckTagsDisplayWidget, 4, 1);
+ upperLayout->addWidget(bannerCardLabel, 4, 0);
+ upperLayout->addWidget(bannerCardComboBox, 4, 1);
- upperLayout->addWidget(activeGroupCriteriaLabel, 5, 0);
- upperLayout->addWidget(activeGroupCriteriaComboBox, 5, 1);
+ upperLayout->addWidget(deckTagsDisplayWidget, 5, 1);
+
+ upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0);
+ upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1);
hashLabel1 = new QLabel();
hashLabel1->setObjectName("hashLabel1");
@@ -305,15 +309,19 @@ void DeckEditorDeckDockWidget::initializeFormats()
// Ensure no selection is visible initially
formatComboBox->setCurrentIndex(-1);
}
-
connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) {
+ QString formatKey;
if (index >= 0) {
- QString formatKey = formatComboBox->itemData(index).toString();
+ formatKey = formatComboBox->itemData(index).toString();
deckStateManager->setFormat(formatKey);
} else {
deckStateManager->setFormat(""); // clear format if deselected
}
+
+ commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
});
+
+ commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
}
ExactCard DeckEditorDeckDockWidget::getCurrentCard()
@@ -495,6 +503,8 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel()
formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat));
formatComboBox->blockSignals(false);
+ commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
+
deckTagsDisplayWidget->blockSignals(true);
deckTagsDisplayWidget->setTags(deckStateManager->getMetadata().tags);
deckTagsDisplayWidget->blockSignals(false);
@@ -748,6 +758,7 @@ void DeckEditorDeckDockWidget::retranslateUi()
commentsLabel->setText(tr("&Comments:"));
activeGroupCriteriaLabel->setText(tr("Group by:"));
formatLabel->setText(tr("Format:"));
+ commanderBracketWidget->retranslateUi();
hashLabel1->setText(tr("Hash:"));
@@ -755,4 +766,4 @@ void DeckEditorDeckDockWidget::retranslateUi()
aDecrement->setText(tr("&Decrement number"));
aRemoveCard->setText(tr("&Remove row"));
aSwapCard->setText(tr("Swap card to/from sideboard"));
-}
\ No newline at end of file
+}
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h
index 8dddf5882..540199f0d 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h
@@ -21,6 +21,7 @@
#include
#include
+class CommanderBracketWidget;
class DeckListModel;
class AbstractTabDeckEditor;
class DeckEditorDeckDockWidget : public QDockWidget
@@ -89,6 +90,8 @@ private:
QAction *aRemoveCard, *aIncrement, *aDecrement, *aSwapCard;
+ CommanderBracketWidget *commanderBracketWidget;
+
DeckListModel *getModel() const;
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h
index 10312d0a0..6fce6be57 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h
@@ -57,6 +57,14 @@ public:
*/
const DeckList &getDeckList() const;
+ /**
+ * @brief Gets the underlying DeckList.
+ */
+ QSharedPointer getDeckListShared() const
+ {
+ return deckList;
+ }
+
/**
* @brief Creates a LoadedDeck containing the contents of the current deck and the current LoadInfo.
*
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp
index f1e7a8ba1..aa8a916f8 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp
@@ -271,6 +271,7 @@ void DlgConnect::updateDisplayInfo(const QString &saveName)
hostEdit->setText(_data.at(1));
portEdit->setText(_data.at(2));
playernameEdit->setText(_data.at(3));
+ playernameEdit->setFocus();
savePasswordCheckBox->setChecked(savePasswordStatus);
if (savePasswordStatus) {
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp
index 267b80a2c..a3242c797 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp
@@ -4,7 +4,6 @@
#include "../../../client/settings/shortcuts_settings.h"
#include "../../deck_loader/card_node_function.h"
#include "../../deck_loader/deck_loader.h"
-#include "dlg_settings.h"
#include
#include
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
index 279e10c28..883cfcd03 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
@@ -1,3 +1,8 @@
+/**
+ * @file dlg_settings.cpp
+ * @brief Implementation of the main settings dialog
+ * @ingroup Dialogs
+ */
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
@@ -6,6 +11,8 @@
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
+#include "../settings_page/settings_search_delegate.h"
+#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
@@ -13,20 +20,36 @@
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_manager.h"
+#include
#include
-#include
#include
+#include
+#include
#include
-#include
+#include
+#include
+#include
+#include
#include
+#include
+#include
#include
#include
#include
+#include
+#include
+#include
#include
+#include
#include
#include
#include
+/**
+ * @brief Wraps a widget in a scroll area for long settings pages
+ * @param widget The widget to wrap
+ * @return The scroll area containing the widget
+ */
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
@@ -40,112 +63,355 @@ static QScrollArea *makeScrollable(QWidget *widget)
return scrollArea;
}
-DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
+/**
+ * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
+ */
+static QStringList pageIconResources()
+{
+ return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
+ QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
+ QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
+ QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
+}
+
+DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
{
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
- this->setMinimumSize(qMin(700, rec.width()), qMin(700, rec.height()));
+ setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
- contentsWidget = new QListWidget;
- contentsWidget->setViewMode(QListView::IconMode);
- contentsWidget->setIconSize(QSize(58, 50));
- contentsWidget->setMovement(QListView::Static);
- contentsWidget->setMinimumHeight(85);
- contentsWidget->setMaximumHeight(85);
- contentsWidget->setSpacing(5);
-
- pagesWidget = new QStackedWidget;
- pagesWidget->addWidget(makeScrollable(new GeneralSettingsPage));
- pagesWidget->addWidget(makeScrollable(new AppearanceSettingsPage));
- pagesWidget->addWidget(makeScrollable(new UserInterfaceSettingsPage));
- pagesWidget->addWidget(new DeckEditorSettingsPage);
- pagesWidget->addWidget(makeScrollable(new StorageSettingsPage));
- pagesWidget->addWidget(new MessagesSettingsPage);
- pagesWidget->addWidget(new SoundSettingsPage);
- pagesWidget->addWidget(new ShortcutSettingsPage);
-
- createIcons();
- contentsWidget->setCurrentRow(0);
-
- auto *vboxLayout = new QVBoxLayout;
- vboxLayout->addWidget(contentsWidget);
- vboxLayout->addWidget(pagesWidget);
-
- auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok);
- connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgSettings::close);
-
- auto *mainLayout = new QVBoxLayout;
- mainLayout->addLayout(vboxLayout);
- mainLayout->addSpacing(2);
- mainLayout->addWidget(buttonBox);
- setLayout(mainLayout);
+ setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
+ searchEdit->setFocus();
+
adjustSize();
}
-void DlgSettings::createIcons()
+void DlgSettings::setupUi()
{
- generalButton = new QListWidgetItem(contentsWidget);
- generalButton->setTextAlignment(Qt::AlignHCenter);
- generalButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- generalButton->setIcon(QPixmap("theme:config/general"));
+ // Search bar
+ searchEdit = new QLineEdit;
+ searchEdit->setClearButtonEnabled(true);
+ searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
+ searchEdit->installEventFilter(this);
+ connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
- appearanceButton = new QListWidgetItem(contentsWidget);
- appearanceButton->setTextAlignment(Qt::AlignHCenter);
- appearanceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- appearanceButton->setIcon(QPixmap("theme:config/appearance"));
+ auto *searchLayout = new QHBoxLayout;
+ searchLayout->addWidget(searchEdit);
- userInterfaceButton = new QListWidgetItem(contentsWidget);
- userInterfaceButton->setTextAlignment(Qt::AlignHCenter);
- userInterfaceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- userInterfaceButton->setIcon(QPixmap("theme:config/interface"));
+ // Tab bar (built in setupTabBar)
+ setupTabBar();
- deckEditorButton = new QListWidgetItem(contentsWidget);
- deckEditorButton->setTextAlignment(Qt::AlignHCenter);
- deckEditorButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- deckEditorButton->setIcon(QPixmap("theme:config/deckeditor"));
+ // Pages stacked widget
+ pagesWidget = new QStackedWidget;
- storageButton = new QListWidgetItem(contentsWidget);
- storageButton->setTextAlignment(Qt::AlignHCenter);
- storageButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- storageButton->setIcon(QPixmap("theme:config/storage"));
+ auto *generalPage = new GeneralSettingsPage;
+ auto *appearancePage = new AppearanceSettingsPage;
+ auto *userInterfacePage = new UserInterfaceSettingsPage;
+ auto *deckEditorPage = new DeckEditorSettingsPage;
+ auto *storagePage = new StorageSettingsPage;
+ auto *messagesPage = new MessagesSettingsPage;
+ auto *soundPage = new SoundSettingsPage;
+ auto *shortcutsPage = new ShortcutSettingsPage;
- messagesButton = new QListWidgetItem(contentsWidget);
- messagesButton->setTextAlignment(Qt::AlignHCenter);
- messagesButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- messagesButton->setIcon(QPixmap("theme:config/messages"));
+ pages.append(generalPage);
+ pages.append(appearancePage);
+ pages.append(userInterfacePage);
+ pages.append(deckEditorPage);
+ pages.append(storagePage);
+ pages.append(messagesPage);
+ pages.append(soundPage);
+ pages.append(shortcutsPage);
- soundButton = new QListWidgetItem(contentsWidget);
- soundButton->setTextAlignment(Qt::AlignHCenter);
- soundButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- soundButton->setIcon(QPixmap("theme:config/sound"));
+ pagesWidget->addWidget(makeScrollable(generalPage));
+ pagesWidget->addWidget(makeScrollable(appearancePage));
+ pagesWidget->addWidget(makeScrollable(userInterfacePage));
+ pagesWidget->addWidget(makeScrollable(deckEditorPage));
+ pagesWidget->addWidget(makeScrollable(storagePage));
+ pagesWidget->addWidget(messagesPage);
+ pagesWidget->addWidget(soundPage);
+ pagesWidget->addWidget(shortcutsPage);
- shortcutsButton = new QListWidgetItem(contentsWidget);
- shortcutsButton->setTextAlignment(Qt::AlignHCenter);
- shortcutsButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
- shortcutsButton->setIcon(QPixmap("theme:config/shorcuts"));
+ Q_ASSERT(pages.size() == NumPages);
- connect(contentsWidget, &QListWidget::currentItemChanged, this, &DlgSettings::changePage);
+ // Search results view (hidden by default)
+ searchResultsView = new QListView;
+ searchResultsView->setUniformItemSizes(false);
+ searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
+ searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
+ searchResultsView->setVisible(false);
+ searchResultsView->setStyleSheet(
+ "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
+
+ searchModel = new SettingsSearchModel(this);
+ searchDelegate = new SettingsSearchDelegate(this);
+ searchResultsView->setModel(searchModel);
+ searchResultsView->setItemDelegate(searchDelegate);
+ connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
+
+ connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
+ const QStringList icons = pageIconResources();
+ for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
+ tabButtons[i]->setIcon(QPixmap(icons[i]));
+ }
+ searchDelegate->setPageIcons(icons);
+ searchResultsView->viewport()->update();
+ });
+
+ // Build search index after pages are created
+ buildSearchIndex();
+
+ // Pages container (stacked widget + search results overlay)
+ pagesContainer = new QWidget;
+ auto *containerLayout = new QStackedLayout;
+ containerLayout->setStackingMode(QStackedLayout::StackAll);
+ containerLayout->addWidget(pagesWidget);
+ containerLayout->addWidget(searchResultsView);
+ pagesContainer->setLayout(containerLayout);
+
+ // Bottom buttons
+ auto *buttonBox = new QHBoxLayout;
+ buttonBox->addStretch();
+ okButton = new QPushButton;
+ okButton->setDefault(true);
+ connect(okButton, &QPushButton::clicked, this, &DlgSettings::close);
+ buttonBox->addWidget(okButton);
+
+ // Main layout
+ auto *mainLayout = new QVBoxLayout;
+ mainLayout->addLayout(searchLayout);
+ mainLayout->addWidget(tabBarWidget);
+ auto *separator = new QFrame;
+ separator->setFrameShape(QFrame::HLine);
+ separator->setFrameShadow(QFrame::Sunken);
+ mainLayout->addWidget(separator);
+ mainLayout->addWidget(pagesContainer);
+ mainLayout->addSpacing(4);
+ mainLayout->addLayout(buttonBox);
+ setLayout(mainLayout);
+
+ // Keyboard shortcuts
+ auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
+ connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
+
+ auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
+ connect(nextTabShortcut, &QShortcut::activated, this, [this] {
+ int next = (currentTabIndex + 1) % tabButtons.size();
+ setActiveTab(next);
+ });
+
+ auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
+ connect(prevTabShortcut, &QShortcut::activated, this, [this] {
+ int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
+ setActiveTab(prev);
+ });
+
+ // Initialize to first tab
+ setActiveTab(0);
}
-void DlgSettings::changePage(QListWidgetItem *current, QListWidgetItem *previous)
+void DlgSettings::setupTabBar()
{
- if (!current) {
- current = previous;
+ tabBarWidget = new QWidget;
+ auto *tabLayout = new QHBoxLayout;
+ tabLayout->setContentsMargins(0, 0, 0, 0);
+ tabLayout->setSpacing(2);
+
+ const QStringList iconResources = pageIconResources();
+
+ for (int i = 0; i < iconResources.size(); ++i) {
+ auto *tabButton = new QToolButton;
+ tabButton->setCheckable(true);
+ tabButton->setIcon(QPixmap(iconResources[i]));
+ tabButton->setIconSize(QSize(48, 48));
+ tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
+ tabButton->setAutoExclusive(true);
+ tabButton->setMinimumHeight(85);
+ tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+
+ connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
+
+ tabButtons.append(tabButton);
+ tabLayout->addWidget(tabButton);
}
- pagesWidget->setCurrentIndex(contentsWidget->row(current));
+ tabBarWidget->setLayout(tabLayout);
+}
+
+void DlgSettings::buildSearchIndex()
+{
+ QList allEntries;
+
+ const QStringList pageNames = translatedPageNames();
+ searchDelegate->setPageNames(pageNames);
+ searchDelegate->setPageIcons(pageIconResources());
+
+ for (int i = 0; i < pages.size(); ++i) {
+ QList pageEntries = pages[i]->getSearchEntries();
+ for (auto &entry : pageEntries) {
+ if (entry.pageIndex == -1) {
+ entry.pageIndex = i;
+ }
+ }
+ allEntries.append(pageEntries);
+ }
+
+ searchModel->setSourceEntries(allEntries);
+}
+
+void DlgSettings::onTabClicked(int index)
+{
+ if (searchActive) {
+ switchToTabMode();
+ }
+ setActiveTab(index);
+}
+
+void DlgSettings::setActiveTab(int index)
+{
+ if (index < 0 || index >= tabButtons.size()) {
+ return;
+ }
+
+ currentTabIndex = index;
+ pagesWidget->setCurrentIndex(index);
+
+ for (int i = 0; i < tabButtons.size(); ++i) {
+ tabButtons[i]->setChecked(i == index);
+ }
+
+ // Style active tab with a thick accent border + subtle background tint
+ for (int i = 0; i < tabButtons.size(); ++i) {
+ if (i == index) {
+ tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
+ "border-top-left-radius: 4px; border-top-right-radius: 4px; "
+ "background: palette(window); padding-bottom: 1px; }");
+ } else {
+ tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
+ "border-top-left-radius: 4px; border-top-right-radius: 4px; "
+ "background: transparent; }");
+ }
+ }
+}
+
+void DlgSettings::flashWidget(QWidget *widget)
+{
+ auto *overlay = new QWidget(widget);
+ overlay->setGeometry(widget->rect());
+ overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
+
+ QPalette pal = overlay->palette();
+ QColor flashColor = pal.color(QPalette::Highlight);
+ flashColor.setAlpha(100);
+ pal.setBrush(QPalette::Window, flashColor);
+ overlay->setPalette(pal);
+ overlay->setAutoFillBackground(true);
+
+ auto *effect = new QGraphicsOpacityEffect(overlay);
+ effect->setOpacity(0.0);
+ overlay->setGraphicsEffect(effect);
+ overlay->show();
+ overlay->raise();
+
+ auto *flashIn = new QPropertyAnimation(effect, "opacity");
+ flashIn->setDuration(120);
+ flashIn->setStartValue(0.0);
+ flashIn->setEndValue(0.6);
+ flashIn->setEasingCurve(QEasingCurve::OutCubic);
+
+ auto *fadeOut = new QPropertyAnimation(effect, "opacity");
+ fadeOut->setDuration(900);
+ fadeOut->setStartValue(0.6);
+ fadeOut->setEndValue(0.0);
+ fadeOut->setEasingCurve(QEasingCurve::InCubic);
+
+ auto *group = new QSequentialAnimationGroup(overlay);
+ group->addAnimation(flashIn);
+ group->addAnimation(fadeOut);
+
+ connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
+
+ group->start(QAbstractAnimation::DeleteWhenStopped);
+}
+
+void DlgSettings::onSearchTextChanged(const QString &text)
+{
+ searchModel->setFilterString(text);
+
+ if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
+ if (!searchActive) {
+ switchToSearchMode();
+ }
+ if (searchModel->rowCount(QModelIndex()) > 0) {
+ searchResultsView->setCurrentIndex(searchModel->index(0));
+ }
+ } else if (searchActive) {
+ switchToTabMode();
+ }
+}
+
+void DlgSettings::switchToSearchMode()
+{
+ searchActive = true;
+ tabBarWidget->setVisible(false);
+ pagesWidget->setVisible(false);
+ searchResultsView->setVisible(true);
+ if (searchModel->rowCount(QModelIndex()) > 0) {
+ searchResultsView->setCurrentIndex(searchModel->index(0));
+ }
+}
+
+void DlgSettings::switchToTabMode()
+{
+ searchActive = false;
+ tabBarWidget->setVisible(true);
+ pagesWidget->setVisible(true);
+ searchResultsView->setVisible(false);
+ searchEdit->blockSignals(true);
+ searchEdit->clear();
+ searchEdit->blockSignals(false);
+ setActiveTab(currentTabIndex);
+}
+
+void DlgSettings::onSearchResultClicked(const QModelIndex &index)
+{
+ navigateToSearchResult(index);
+}
+
+void DlgSettings::navigateToSearchResult(const QModelIndex &index)
+{
+ SettingsSearchEntry entry = searchModel->entryForIndex(index);
+ if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
+ return;
+ }
+
+ // Switch to the page
+ switchToTabMode();
+ setActiveTab(entry.pageIndex);
+
+ // Scroll to the widget, focus it, and flash to highlight it
+ if (entry.widget) {
+ QWidget *widget = entry.widget;
+ while (widget) {
+ if (auto *scrollArea = qobject_cast(widget)) {
+ scrollArea->ensureWidgetVisible(entry.widget);
+ break;
+ }
+ widget = widget->parentWidget();
+ }
+ entry.widget->setFocus();
+ flashWidget(entry.widget);
+ }
}
void DlgSettings::setTab(int index)
{
- if (index <= contentsWidget->count() - 1 && index >= 0) {
- changePage(contentsWidget->item(index), contentsWidget->currentItem());
- contentsWidget->setCurrentRow(index);
+ if (index >= 0 && index < tabButtons.size()) {
+ setActiveTab(index);
}
}
@@ -155,6 +421,49 @@ void DlgSettings::updateLanguage()
installNewTranslator();
}
+bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
+{
+ if (watched == searchEdit && event->type() == QEvent::KeyPress) {
+ auto *keyEvent = static_cast(event);
+ if (keyEvent->key() == Qt::Key_Escape) {
+ if (searchActive) {
+ switchToTabMode();
+ return true;
+ }
+ } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
+ if (searchActive) {
+ if (searchResultsView->currentIndex().isValid()) {
+ navigateToSearchResult(searchResultsView->currentIndex());
+ }
+ return true;
+ }
+ } else if (keyEvent->key() == Qt::Key_Down) {
+ if (searchActive) {
+ int nextRow = searchResultsView->currentIndex().row() + 1;
+ if (nextRow >= searchModel->rowCount()) {
+ nextRow = 0;
+ }
+ searchResultsView->setCurrentIndex(searchModel->index(nextRow));
+ searchResultsView->scrollTo(searchModel->index(nextRow));
+ return true;
+ }
+ } else if (keyEvent->key() == Qt::Key_Up) {
+ if (searchActive) {
+ int prevRow = searchResultsView->currentIndex().row() - 1;
+ if (prevRow < 0) {
+ prevRow = searchModel->rowCount() - 1;
+ }
+ if (prevRow >= 0) {
+ searchResultsView->setCurrentIndex(searchModel->index(prevRow));
+ searchResultsView->scrollTo(searchModel->index(prevRow));
+ }
+ return true;
+ }
+ }
+ }
+ return QDialog::eventFilter(watched, event);
+}
+
void DlgSettings::closeEvent(QCloseEvent *event)
{
bool showLoadError = true;
@@ -209,7 +518,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() ||
SettingsCache::instance().paths().getDeckPath().isEmpty()) {
- //! \todo Prompt to create the deck directory.
if (QMessageBox::critical(
this, tr("Error"),
tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"),
@@ -221,7 +529,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() ||
SettingsCache::instance().paths().getPicsPath().isEmpty()) {
- //! \todo Prompt to create the pictures directory.
if (QMessageBox::critical(this, tr("Error"),
tr("The path to your card pictures directory is invalid. Would you like to go back "
"and set the correct path?"),
@@ -236,15 +543,26 @@ void DlgSettings::closeEvent(QCloseEvent *event)
void DlgSettings::retranslateUi()
{
setWindowTitle(tr("Settings"));
+ retranslateTabNames();
- generalButton->setText(tr("General"));
- appearanceButton->setText(tr("Appearance"));
- userInterfaceButton->setText(tr("User Interface"));
- storageButton->setText(tr("Storage"));
- deckEditorButton->setText(tr("Card Sources"));
- messagesButton->setText(tr("Chat"));
- soundButton->setText(tr("Sound"));
- shortcutsButton->setText(tr("Shortcuts"));
+ searchEdit->setPlaceholderText(tr("Search settings..."));
+ okButton->setText(tr("OK"));
- contentsWidget->reset();
+ // Rebuild search index for translated text
+ buildSearchIndex();
+}
+
+QStringList DlgSettings::translatedPageNames()
+{
+ return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
+ tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
+}
+
+void DlgSettings::retranslateTabNames()
+{
+ const QStringList tabLabels = translatedPageNames();
+
+ for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
+ tabButtons[i]->setText(tabLabels[i]);
+ }
}
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h
index 3ffee6388..b700f7af9 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h
@@ -1,43 +1,99 @@
/**
* @file dlg_settings.h
+ * @brief Main settings dialog for the Cockatrice client
* @ingroup Dialogs
*/
-//! \todo Document this file.
-
#ifndef DLG_SETTINGS_H
#define DLG_SETTINGS_H
-#include
#include
#include
+class QPushButton;
+
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
-class QListWidget;
class QStackedWidget;
-class QListWidgetItem;
+class QToolButton;
+class QListView;
+class QLineEdit;
+class AbstractSettingsPage;
+class SettingsSearchModel;
+class SettingsSearchDelegate;
+
+/**
+ * @brief Main application settings dialog with tabbed navigation and search
+ *
+ * Provides a modern settings interface organized into tabbed pages. Users can
+ * either navigate by clicking tabs or search for specific settings using the
+ * built-in search bar. Search results are filtered and ranked by relevance.
+ */
class DlgSettings : public QDialog
{
Q_OBJECT
public:
+ /**
+ * @brief Page order in the tab bar, matching the order pages are added in setupUi()
+ *
+ * Use these values instead of raw indices so reordering pages never silently
+ * breaks external callers like tab_room.cpp.
+ */
+ enum SettingsPage
+ {
+ GeneralPage = 0,
+ AppearancePage,
+ UserInterfacePage,
+ DeckEditorPage,
+ StoragePage,
+ MessagesPage,
+ SoundPage,
+ ShortcutsPage,
+ NumPages
+ };
+
explicit DlgSettings(QWidget *parent = nullptr);
void setTab(int index);
private slots:
- void changePage(QListWidgetItem *current, QListWidgetItem *previous);
+ void onTabClicked(int index);
+ void onSearchTextChanged(const QString &text);
+ void onSearchResultClicked(const QModelIndex &index);
void updateLanguage();
private:
- QListWidget *contentsWidget;
- QStackedWidget *pagesWidget;
- QListWidgetItem *generalButton, *appearanceButton, *userInterfaceButton, *deckEditorButton, *storageButton,
- *messagesButton, *soundButton, *shortcutsButton;
- void createIcons();
+ // UI elements
+ QLineEdit *searchEdit; ///< Search bar for filtering settings
+ QWidget *tabBarWidget; ///< Container widget for the tab buttons
+ QList tabButtons; ///< Navigation tab buttons
+ QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
+ QListView *searchResultsView; ///< Search results list view
+ QWidget *pagesContainer; ///< Container stacking pages and search results
+ QPushButton *okButton; ///< Button to close the dialog
+
+ // Data
+ QList pages; ///< All settings page instances
+ SettingsSearchModel *searchModel; ///< Model for search results
+ SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
+ int currentTabIndex; ///< Currently active tab index
+ bool searchActive; ///< Whether search mode is active
+
+ void setupUi();
+ void setupTabBar();
+ void buildSearchIndex();
+ void switchToTabMode();
+ void switchToSearchMode();
+ void navigateToSearchResult(const QModelIndex &index);
+ void setActiveTab(int index);
+ static void flashWidget(QWidget *widget);
+ static QStringList translatedPageNames();
+
void retranslateUi();
+ void retranslateTabNames();
protected:
void closeEvent(QCloseEvent *event) override;
+ bool eventFilter(QObject *watched, QEvent *event) override;
};
-#endif
+#endif // DLG_SETTINGS_H
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp
index ee2149309..7cf58d3e0 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp
@@ -134,7 +134,7 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas
// If there's no need to update, tell them that. However we still allow them to run the
// downloader themselves if there's a compatible build
QMessageBox::information(
- this, tr("No Update Available"),
+ window(), tr("No Update Available"),
tr("Cockatrice is up to date!") + "
" +
tr("You are already running the latest version available in the chosen release channel.") + "
" +
"" + tr("Current version") + QString(": %1
").arg(VERSION_STRING) + "" +
@@ -147,7 +147,7 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas
if (isCompatible) {
int reply;
reply = QMessageBox::question(
- this, tr("Update Available"),
+ window(), tr("Update Available"),
tr("A new version of Cockatrice is available!") + "
" + "" + tr("New version") +
QString(": %1
").arg(release->getName()) + "" + tr("Released") +
QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") +
@@ -156,10 +156,12 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas
if (reply == QMessageBox::Yes) {
downloadUpdate(release->getName());
+ } else {
+ closeDialog();
}
} else {
QMessageBox::information(
- this, tr("Update Available"),
+ window(), tr("Update Available"),
tr("A new version of Cockatrice is available!") + "
" + "" + tr("New version") +
QString(": %1
").arg(release->getName()) + "" + tr("Released") +
QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") +
diff --git a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp
index b71b24a40..bd4641981 100644
--- a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp
+++ b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp
@@ -82,9 +82,7 @@ void ColorPie::paintEvent(QPaintEvent *)
QString label = QString("%1%").arg(int(ratio * 100 + 0.5));
QFontMetrics fm(p.font());
-
int labelWidth = fm.horizontalAdvance(label);
-
QRectF textRect(labelPos.x() - labelWidth / 2.0, labelPos.y() - fm.height() / 2.0, labelWidth, fm.height());
p.setPen(Qt::black);
diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp
index e873f5f3e..8589e3517 100644
--- a/cockatrice/src/interface/widgets/general/home_widget.cpp
+++ b/cockatrice/src/interface/widgets/general/home_widget.cpp
@@ -14,8 +14,8 @@
#include
#include
#include
+#include
#include
-#include
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
: QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice")
@@ -43,12 +43,12 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
updateConnectButton(tabSupervisor->getClient()->getStatus());
connect(tabSupervisor->getClient(), &RemoteClient::statusChanged, this, &HomeWidget::updateConnectButton);
- connect(&SettingsCache::instance().personal(), &PersonalSettings::homeTabBackgroundSourceChanged, this,
+ connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundSourceChanged, this,
&HomeWidget::initializeBackgroundFromSource);
- connect(&SettingsCache::instance().personal(), &PersonalSettings::homeTabBackgroundShuffleFrequencyChanged, this,
- &HomeWidget::onBackgroundShuffleFrequencyChanged);
+ connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundShuffleFrequencyChanged,
+ this, &HomeWidget::onBackgroundShuffleFrequencyChanged);
// Lambda is cleaner to read than overloading this
- connect(&SettingsCache::instance().personal(), &PersonalSettings::homeTabDisplayCardNameChanged, this,
+ connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this,
[this] { repaint(); });
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
&HomeWidget::initializeBackgroundFromSource);
@@ -65,7 +65,7 @@ void HomeWidget::initializeBackgroundFromSource()
}
auto backgroundSourceType =
- BackgroundSources::fromId(SettingsCache::instance().personal().getHomeTabBackgroundSource());
+ BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource());
switch (backgroundSourceType) {
case BackgroundSources::Theme:
@@ -113,7 +113,7 @@ void HomeWidget::setRandomCard(ExactCard &newCard)
void HomeWidget::updateRandomCard()
{
auto backgroundSourceType =
- BackgroundSources::fromId(SettingsCache::instance().personal().getHomeTabBackgroundSource());
+ BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource());
ExactCard newCard;
@@ -156,8 +156,8 @@ void HomeWidget::updateRandomCard()
void HomeWidget::onBackgroundShuffleFrequencyChanged()
{
cardChangeTimer->stop();
- if (SettingsCache::instance().personal().getHomeTabBackgroundShuffleFrequency() > 0) {
- cardChangeTimer->start(SettingsCache::instance().personal().getHomeTabBackgroundShuffleFrequency() * 1000);
+ if (SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() > 0) {
+ cardChangeTimer->start(SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() * 1000);
}
}
@@ -265,7 +265,7 @@ void HomeWidget::updateConnectButton(const ClientStatus status)
QPair HomeWidget::extractDominantColors(const QPixmap &pixmap)
{
- if (themeManager->isBuiltInTheme() && SettingsCache::instance().personal().getHomeTabBackgroundSource() ==
+ if (themeManager->isBuiltInTheme() && SettingsCache::instance().appearance().getHomeTabBackgroundSource() ==
BackgroundSources::toId(BackgroundSources::Theme)) {
return QPair(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80));
}
@@ -352,7 +352,7 @@ void HomeWidget::paintEvent(QPaintEvent *event)
}
}
- if (!cardName.isEmpty() && SettingsCache::instance().personal().getHomeTabDisplayCardName()) {
+ if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) {
QFont font = painter.font();
font.setPointSize(14);
font.setBold(true);
diff --git a/cockatrice/src/interface/widgets/menus/tearoff_menu.h b/cockatrice/src/interface/widgets/menus/tearoff_menu.h
index 26dcd1f6c..9a9c4ff01 100644
--- a/cockatrice/src/interface/widgets/menus/tearoff_menu.h
+++ b/cockatrice/src/interface/widgets/menus/tearoff_menu.h
@@ -16,16 +16,16 @@ class TearOffMenu : public QMenu
public:
explicit TearOffMenu(const QString &title, QWidget *parent = nullptr) : QMenu(title, parent)
{
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::useTearOffMenusChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this,
[this](const bool state) { setTearOffEnabled(state); });
- setTearOffEnabled(SettingsCache::instance().interface().getUseTearOffMenus());
+ setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus());
}
explicit TearOffMenu(QWidget *parent = nullptr) : QMenu(parent)
{
- connect(&SettingsCache::instance().interface(), &InterfaceSettings::useTearOffMenusChanged, this,
+ connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this,
[this](const bool state) { setTearOffEnabled(state); });
- setTearOffEnabled(SettingsCache::instance().interface().getUseTearOffMenus());
+ setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus());
}
TearOffMenu *addTearOffMenu(const QString &title)
diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp
new file mode 100644
index 000000000..a2c1e0ff0
--- /dev/null
+++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp
@@ -0,0 +1,234 @@
+#include "replay_manager.h"
+
+#include "../../../client/settings/cache_settings.h"
+
+#include
+#include
+#include
+
+static constexpr int TIMER_INTERVAL_MS = 200;
+static constexpr int EMPTY_SECTION_MARGIN_MS = 500;
+
+static QList createReplayTimeline(const GameReplay *replay)
+{
+ // Create list: event number -> time [ms]
+ unsigned int lastEventTimestamp = 0;
+ const int eventCount = replay->event_list_size();
+
+ QList replayTimeline;
+ for (int i = 0; i < eventCount; ++i) {
+ int nextSecondIndex = i + 1;
+ while (nextSecondIndex < eventCount &&
+ replay->event_list(nextSecondIndex).seconds_elapsed() == lastEventTimestamp) {
+ ++nextSecondIndex;
+ }
+
+ // Distribute simultaneous events evenly across 1 second.
+ const int numberEventsThisSecond = nextSecondIndex - i;
+ for (int k = 0; k < numberEventsThisSecond; ++k) {
+ int eventMs = replay->event_list(i + k).seconds_elapsed() * 1000;
+ int distributionMs = static_cast(static_cast(k) / numberEventsThisSecond * 1000);
+ replayTimeline.append(eventMs + distributionMs);
+ }
+
+ if (nextSecondIndex < eventCount) {
+ lastEventTimestamp = replay->event_list(nextSecondIndex).seconds_elapsed();
+ }
+ i += numberEventsThisSecond - 1;
+ }
+
+ return replayTimeline;
+}
+
+ReplayManager::ReplayManager(QObject *parent, GameReplay *replay)
+ : QObject(parent), replay(replay), replayTimeline(createReplayTimeline(replay))
+{
+ maxTime = replayTimeline.isEmpty() ? 0 : replayTimeline.last();
+
+ replayTimer = new QTimer(this);
+ replayTimer->setInterval(TIMER_INTERVAL_MS);
+ connect(replayTimer, &QTimer::timeout, this, &ReplayManager::replayTimerTimeout);
+
+ rewindBufferingTimer = new QTimer(this);
+ rewindBufferingTimer->setSingleShot(true);
+ connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind);
+}
+
+ReplayManager::~ReplayManager()
+{
+ delete replay;
+}
+
+void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
+{
+ // check boundary conditions
+ if (newTime < 0) {
+ newTime = 0;
+ }
+ if (newTime > maxTime) {
+ newTime = maxTime;
+ }
+
+ newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
+
+ const bool isBackwardsSkip = newTime < currentProcessedTime;
+ currentVisualTime = newTime;
+
+ if (isBackwardsSkip) {
+ handleBackwardsSkip(doRewindBuffering);
+ } else {
+ processNewEvents(FORWARD_SKIP);
+ }
+
+ timeChanged(currentVisualTime);
+}
+
+/**
+ * @brief Handles a backwards skip in the replay timeline.
+ *
+ * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind
+ * is processed at the end. When false, the backwards skip will always cause an immediate rewind.
+ */
+void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
+{
+ if (doRewindBuffering) {
+ // We use a one-shot timer to implement the rewind buffering.
+ // The rewind only happens once the timer runs out.
+ // If another backwards skip happens, the timer will just get reset instead of rewinding.
+ rewindBufferingTimer->stop();
+ rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs());
+ } else {
+ // otherwise, process the rewind immediately
+ processRewind();
+ }
+}
+
+void ReplayManager::processRewind()
+{
+ // stop any queued-up rewinds
+ rewindBufferingTimer->stop();
+
+ // process the rewind
+ currentEvent = 0;
+ emit rewound();
+ processNewEvents(BACKWARD_SKIP);
+}
+
+void ReplayManager::replayTimerTimeout()
+{
+ currentVisualTime += TIMER_INTERVAL_MS;
+
+ processNewEvents(NORMAL_PLAYBACK);
+
+ timeChanged(currentVisualTime);
+
+ if (skipEmptySections) {
+ handleSkipEmptySection();
+ }
+}
+
+/** @brief Processes all unprocessed events up to the current time. */
+void ReplayManager::processNewEvents(PlaybackMode playbackMode)
+{
+ currentProcessedTime = currentVisualTime;
+
+ while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) {
+ EventProcessingOptions options;
+
+ // backwards skip => always skip reveal windows
+ // forwards skip => skip reveal windows that don't happen within a big skip of the target
+ if (playbackMode == BACKWARD_SKIP || currentProcessedTime - replayTimeline[currentEvent] > BIG_SKIP_MS) {
+ options |= SKIP_REVEAL_WINDOW;
+ }
+
+ // backwards skip => always skip tap animation
+ if (playbackMode == BACKWARD_SKIP) {
+ options |= SKIP_TAP_ANIMATION;
+ }
+
+ emit eventReplayed(replay->event_list(currentEvent), options);
+ ++currentEvent;
+ }
+ if (currentEvent == replayTimeline.size()) {
+ emit replayFinished();
+ replayTimer->stop();
+ }
+}
+
+static bool hasMeaningfulEvent(const GameEventContainer &cont)
+{
+ const int eventListSize = cont.event_list_size();
+ for (int i = 0; i < eventListSize; ++i) {
+ const GameEvent &event = cont.event_list(i);
+ const auto eventType = static_cast(getPbExtension(event));
+
+ if (eventType != GameEvent::PLAYER_PROPERTIES_CHANGED) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+void ReplayManager::handleSkipEmptySection()
+{
+ if (currentEvent == replayTimeline.size()) {
+ return;
+ }
+
+ // find most recent meaningful event
+ int prevEvent = std::max(0, currentEvent - 1);
+ for (; prevEvent > 0 && !hasMeaningfulEvent(replay->event_list(prevEvent)); --prevEvent) {
+ }
+
+ int prevEventTime = replayTimeline.value(prevEvent);
+ if (currentVisualTime - prevEventTime <= EMPTY_SECTION_MARGIN_MS) {
+ return;
+ }
+
+ // find next earliest meaningful event
+ int nextEvent = currentEvent;
+ for (; nextEvent < replayTimeline.size() - 1 && !hasMeaningfulEvent(replay->event_list(nextEvent)); ++nextEvent) {
+ }
+
+ int nextEventTime = replayTimeline.value(nextEvent);
+ if (nextEventTime - currentVisualTime <= EMPTY_SECTION_MARGIN_MS) {
+ return;
+ }
+
+ // skip forward if we're not within margin of either event
+ skipToTime(nextEventTime - EMPTY_SECTION_MARGIN_MS, false);
+}
+
+void ReplayManager::setTimeScaleFactor(qreal _timeScaleFactor)
+{
+ timeScaleFactor = _timeScaleFactor;
+ int interval = std::max(1, qRound(TIMER_INTERVAL_MS / timeScaleFactor));
+ replayTimer->setInterval(interval);
+}
+
+void ReplayManager::setSkipEmptySections(bool value)
+{
+ skipEmptySections = value;
+}
+
+void ReplayManager::startReplay()
+{
+ replayTimer->start();
+}
+
+void ReplayManager::stopReplay()
+{
+ replayTimer->stop();
+}
+
+void ReplayManager::setTime(int time)
+{
+ // don't buffer rewinds from clicks, since clicks usually don't happen fast enough to require buffering
+ skipToTime(time, false);
+}
+
+void ReplayManager::skipByAmount(int amount)
+{
+ skipToTime(currentVisualTime + amount, amount < 0);
+}
\ No newline at end of file
diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h
new file mode 100644
index 000000000..81e66824d
--- /dev/null
+++ b/cockatrice/src/interface/widgets/replay/replay_manager.h
@@ -0,0 +1,83 @@
+#ifndef COCKATRICE_REPLAY_MANAGER_H
+#define COCKATRICE_REPLAY_MANAGER_H
+
+#include "../../../game/player/event_processing_options.h"
+
+#include
+#include
+
+class GameReplay;
+class QTimer;
+
+/**
+ * @brief This class handles all logic to do with playing back replays
+ */
+class ReplayManager : public QObject
+{
+ Q_OBJECT
+
+ enum PlaybackMode
+ {
+ NORMAL_PLAYBACK,
+ FORWARD_SKIP,
+ BACKWARD_SKIP
+ };
+
+ GameReplay *replay;
+ QList replayTimeline; ///< timestamp of each event, with the indexes corresponding
+ int maxTime;
+
+ QTimer *replayTimer;
+ QTimer *rewindBufferingTimer;
+
+ qreal timeScaleFactor = 1.0;
+ bool skipEmptySections = false;
+
+ int currentVisualTime = 0; ///< time currently displayed by the timeline
+ int currentProcessedTime = 0; ///< time that events are currently processed up to. Could differ from visual time due
+ ///< to rewind buffering
+ int currentEvent = 0; ///< current event's index
+
+ void skipToTime(int newTime, bool doRewindBuffering);
+ void handleBackwardsSkip(bool doRewindBuffering);
+ void processRewind();
+ void processNewEvents(PlaybackMode playbackMode);
+ void handleSkipEmptySection();
+
+private slots:
+ void replayTimerTimeout();
+
+public:
+ static constexpr int SMALL_SKIP_MS = 1000;
+ static constexpr int BIG_SKIP_MS = 10000;
+
+ /**
+ * @param parent The parent QObject
+ * @param replay Cannot be null. Takes ownership of the object.
+ */
+ explicit ReplayManager(QObject *parent, GameReplay *replay);
+
+ ~ReplayManager() override;
+
+ const QList &getReplayTimeline() const
+ {
+ return replayTimeline;
+ }
+
+ void setTimeScaleFactor(qreal _timeScaleFactor);
+ void setSkipEmptySections(bool value);
+
+public slots:
+ void startReplay();
+ void stopReplay();
+ void setTime(int time);
+ void skipByAmount(int amount); // use a negative amount to skip backwards
+
+signals:
+ void timeChanged(int time);
+ void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options);
+ void replayFinished();
+ void rewound();
+};
+
+#endif // COCKATRICE_REPLAY_MANAGER_H
diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp
index 446427e26..5d58705d2 100644
--- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp
+++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp
@@ -14,16 +14,21 @@ ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : Settings
fastForwardSpeedBox.setMinimum(1);
fastForwardSpeedBox.setMaximum(99.9);
fastForwardSpeedBox.setDecimals(1);
- fastForwardSpeedBox.setValue(SettingsCache::instance().interface().getFastForwardSpeed());
+ fastForwardSpeedBox.setValue(SettingsCache::instance().userInterface().getFastForwardSpeed());
connect(&fastForwardSpeedBox, qOverload(&QDoubleSpinBox::valueChanged), this,
&ReplayQuickSettingsWidget::actUpdateFastForwardSpeed);
+ skipEmptyCheckBox.setChecked(SettingsCache::instance().userInterface().getSkipEmptySections());
+ connect(&skipEmptyCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
+ &ReplayQuickSettingsWidget::actUpdateSkipEmptySections);
+
// putting it all together
auto *widget = new QWidget;
auto *grid = new QGridLayout(widget);
grid->setContentsMargins(0, 0, 0, 0);
grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1);
grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1);
+ grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2);
this->addSettingsWidget(widget);
@@ -36,10 +41,18 @@ void ReplayQuickSettingsWidget::retranslateUi()
{
fastForwardSpeedLabel.setText(tr("Fast forward speed:"));
fastForwardSpeedBox.setSuffix("x");
+
+ skipEmptyCheckBox.setText(tr("Skip empty sections"));
}
void ReplayQuickSettingsWidget::actUpdateFastForwardSpeed(qreal value)
{
- SettingsCache::instance().interface().setFastForwardSpeed(value);
+ SettingsCache::instance().userInterface().setFastForwardSpeed(value);
emit fastForwardSpeedChanged(value);
}
+
+void ReplayQuickSettingsWidget::actUpdateSkipEmptySections(QT_STATE_CHANGED_T value)
+{
+ SettingsCache::instance().userInterface().setSkipEmptySections(value);
+ emit skipEmptySectionsChanged(value);
+}
diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h
index b88a8b4e3..a337ea0a6 100644
--- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h
+++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h
@@ -3,7 +3,9 @@
#include "../../interface/widgets/quick_settings/settings_button_widget.h"
+#include
#include
+#include
class ReplayQuickSettingsWidget : public SettingsButtonWidget
{
@@ -16,13 +18,17 @@ public:
signals:
void fastForwardSpeedChanged(qreal speed);
+ void skipEmptySectionsChanged(bool skip);
private:
QLabel fastForwardSpeedLabel;
QDoubleSpinBox fastForwardSpeedBox;
+ QCheckBox skipEmptyCheckBox;
+
private slots:
void actUpdateFastForwardSpeed(qreal value);
+ void actUpdateSkipEmptySections(QT_STATE_CHANGED_T value);
};
#endif // COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H
diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp
index 8ec20b267..fe4ac330b 100644
--- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp
+++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp
@@ -4,26 +4,19 @@
#include
#include
-#include
-#include
-ReplayTimelineWidget::ReplayTimelineWidget(QWidget *parent)
- : QWidget(parent), maxBinValue(1), maxTime(1), timeScaleFactor(1.0), currentVisualTime(0), currentProcessedTime(0),
- currentEvent(0)
+static constexpr int BIN_LENGTH = 5000;
+static constexpr int MIN_RESOLUTION_MS = 1000;
+
+ReplayTimelineWidget::ReplayTimelineWidget(QWidget *parent) : QWidget(parent)
{
- replayTimer = new QTimer(this);
- replayTimer->setInterval(TIMER_INTERVAL_MS);
- connect(replayTimer, &QTimer::timeout, this, &ReplayTimelineWidget::replayTimerTimeout);
-
- rewindBufferingTimer = new QTimer(this);
- rewindBufferingTimer->setSingleShot(true);
- connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayTimelineWidget::processRewind);
}
-void ReplayTimelineWidget::setTimeline(const QList &_replayTimeline)
+void ReplayTimelineWidget::setTimeline(const QList &replayTimeline)
{
- replayTimeline = _replayTimeline;
histogram.clear();
+ currentTime = 0;
+
int binEndTime = BIN_LENGTH - 1;
int binValue = 0;
for (int i : replayTimeline) {
@@ -66,70 +59,31 @@ void ReplayTimelineWidget::paintEvent(QPaintEvent * /* event */)
painter.fillPath(path, Qt::black);
const QColor barColor = QColor::fromHsv(120, 255, 255, 100);
- quint64 w = (quint64)(width() - 1) * (quint64)currentVisualTime / maxTime;
+ quint64 w = (quint64)(width() - 1) * (quint64)currentTime / maxTime;
painter.fillRect(0, 0, static_cast(w), height() - 1, barColor);
}
void ReplayTimelineWidget::mousePressEvent(QMouseEvent *event)
{
int newTime = static_cast((qint64)maxTime * (qint64)event->position().x() / width());
- // don't buffer rewinds from clicks, since clicks usually don't happen fast enough to require buffering
- skipToTime(newTime, false);
+ emit timeClicked(newTime);
}
-void ReplayTimelineWidget::skipToTime(int newTime, bool doRewindBuffering)
+void ReplayTimelineWidget::setCurrentTime(int time)
{
- // check boundary conditions
- if (newTime < 0) {
- newTime = 0;
- }
- if (newTime > maxTime) {
- newTime = maxTime;
+ int newTime = qBound(0, time, maxTime);
+
+ if (currentTime == newTime) {
+ return;
}
- newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
+ bool doUpdate = currentTime / MIN_RESOLUTION_MS != newTime / MIN_RESOLUTION_MS;
- const bool isBackwardsSkip = newTime < currentProcessedTime;
- currentVisualTime = newTime;
+ currentTime = newTime;
- if (isBackwardsSkip) {
- handleBackwardsSkip(doRewindBuffering);
- } else {
- processNewEvents(FORWARD_SKIP);
+ if (doUpdate) {
+ update();
}
-
- update();
-}
-
-/**
- * @brief Handles a backwards skip in the replay timeline.
- *
- * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind
- * is processed at the end. When false, the backwards skip will always cause an immediate rewind.
- */
-void ReplayTimelineWidget::handleBackwardsSkip(bool doRewindBuffering)
-{
- if (doRewindBuffering) {
- // We use a one-shot timer to implement the rewind buffering.
- // The rewind only happens once the timer runs out.
- // If another backwards skip happens, the timer will just get reset instead of rewinding.
- rewindBufferingTimer->stop();
- rewindBufferingTimer->start(SettingsCache::instance().interface().getRewindBufferingMs());
- } else {
- // otherwise, process the rewind immediately
- processRewind();
- }
-}
-
-void ReplayTimelineWidget::processRewind()
-{
- // stop any queued-up rewinds
- rewindBufferingTimer->stop();
-
- // process the rewind
- currentEvent = 0;
- emit rewound();
- processNewEvents(BACKWARD_SKIP);
}
QSize ReplayTimelineWidget::sizeHint() const
@@ -141,64 +95,3 @@ QSize ReplayTimelineWidget::minimumSizeHint() const
{
return {400, 50};
}
-
-void ReplayTimelineWidget::replayTimerTimeout()
-{
- currentVisualTime += TIMER_INTERVAL_MS;
-
- processNewEvents(NORMAL_PLAYBACK);
-
- if (!(currentVisualTime % 1000)) {
- update();
- }
-}
-
-/** @brief Processes all unprocessed events up to the current time. */
-void ReplayTimelineWidget::processNewEvents(PlaybackMode playbackMode)
-{
- currentProcessedTime = currentVisualTime;
-
- while ((currentEvent < replayTimeline.size()) && (replayTimeline[currentEvent] < currentProcessedTime)) {
- EventProcessingOptions options;
-
- // backwards skip => always skip reveal windows
- // forwards skip => skip reveal windows that don't happen within a big skip of the target
- if (playbackMode == BACKWARD_SKIP || currentProcessedTime - replayTimeline[currentEvent] > BIG_SKIP_MS) {
- options |= SKIP_REVEAL_WINDOW;
- }
-
- // backwards skip => always skip tap animation
- if (playbackMode == BACKWARD_SKIP) {
- options |= SKIP_TAP_ANIMATION;
- }
-
- emit processNextEvent(options);
- ++currentEvent;
- }
- if (currentEvent == replayTimeline.size()) {
- emit replayFinished();
- replayTimer->stop();
- }
-}
-
-void ReplayTimelineWidget::setTimeScaleFactor(qreal _timeScaleFactor)
-{
- timeScaleFactor = _timeScaleFactor;
- int interval = std::max(1, qRound(TIMER_INTERVAL_MS / timeScaleFactor));
- replayTimer->setInterval(interval);
-}
-
-void ReplayTimelineWidget::startReplay()
-{
- replayTimer->start();
-}
-
-void ReplayTimelineWidget::stopReplay()
-{
- replayTimer->stop();
-}
-
-void ReplayTimelineWidget::skipByAmount(int amount)
-{
- skipToTime(currentVisualTime + amount, amount < 0);
-}
\ No newline at end of file
diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h
index 6cdb8bcb2..47d19a741 100644
--- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h
+++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h
@@ -18,57 +18,25 @@ class QTimer;
class ReplayTimelineWidget : public QWidget
{
Q_OBJECT
+
signals:
- void processNextEvent(EventProcessingOptions options);
- void replayFinished();
- void rewound();
+ void timeClicked(int newTime);
private:
- enum PlaybackMode
- {
- NORMAL_PLAYBACK,
- FORWARD_SKIP,
- BACKWARD_SKIP
- };
-
- static constexpr int TIMER_INTERVAL_MS = 200;
- static constexpr int BIN_LENGTH = 5000;
-
- QTimer *replayTimer;
- QTimer *rewindBufferingTimer;
- QList replayTimeline;
QList histogram;
- int maxBinValue, maxTime;
- qreal timeScaleFactor;
- int currentVisualTime; // time currently displayed by the timeline
- int currentProcessedTime; // time that events are currently processed up to. Could differ from visual time due to
- // rewind buffering
- int currentEvent;
+ int maxBinValue = 1;
+ int maxTime = 1;
- void skipToTime(int newTime, bool doRewindBuffering);
- void handleBackwardsSkip(bool doRewindBuffering);
- void processRewind();
- void processNewEvents(PlaybackMode playbackMode);
-private slots:
- void replayTimerTimeout();
+ int currentTime = 0;
public:
- static constexpr int SMALL_SKIP_MS = 1000;
- static constexpr int BIG_SKIP_MS = 10000;
-
explicit ReplayTimelineWidget(QWidget *parent = nullptr);
- void setTimeline(const QList &_replayTimeline);
+ void setTimeline(const QList &replayTimeline);
[[nodiscard]] QSize sizeHint() const override;
[[nodiscard]] QSize minimumSizeHint() const override;
- void setTimeScaleFactor(qreal _timeScaleFactor);
- [[nodiscard]] int getCurrentEvent() const
- {
- return currentEvent;
- }
+
public slots:
- void startReplay();
- void stopReplay();
- void skipByAmount(int amount); // use a negative amount to skip backwards
+ void setCurrentTime(int time);
protected:
void paintEvent(QPaintEvent *event) override;
diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp
index 4dc7b1380..6c85d950e 100644
--- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp
+++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp
@@ -1,70 +1,50 @@
#include "replay_widget.h"
+#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../interface/widgets/tabs/tab_game.h"
+#include "replay_manager.h"
#include "replay_quick_settings_widget.h"
#include
#include
-ReplayWidget::ReplayWidget(TabGame *parent, GameReplay *_replay)
- : QWidget(parent), game(parent), replay(_replay), replayPlayButton(nullptr), replayFastForwardButton(nullptr),
- aReplaySkipForward(nullptr), aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr),
- aReplaySkipBackwardBig(nullptr)
+ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
+ : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr),
+ aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr)
{
- if (replay) {
- game->getGame()->loadReplay(replay);
-
- // Create list: event number -> time [ms]
- // Distribute simultaneous events evenly across 1 second.
- unsigned int lastEventTimestamp = 0;
- const int eventCount = replay->event_list_size();
- for (int i = 0; i < eventCount; ++i) {
- int j = i + 1;
- while ((j < eventCount) && (replay->event_list(j).seconds_elapsed() == lastEventTimestamp)) {
- ++j;
- }
-
- const int numberEventsThisSecond = j - i;
- for (int k = 0; k < numberEventsThisSecond; ++k) {
- replayTimeline.append(replay->event_list(i + k).seconds_elapsed() * 1000 +
- (int)((qreal)k / (qreal)numberEventsThisSecond * 1000));
- }
-
- if (j < eventCount) {
- lastEventTimestamp = replay->event_list(j).seconds_elapsed();
- }
- i += numberEventsThisSecond - 1;
- }
- }
+ // replay manager
+ replayManager = new ReplayManager(this, replay);
+ connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed);
+ connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished);
+ connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound);
// timeline widget
timelineWidget = new ReplayTimelineWidget;
- timelineWidget->setTimeline(replayTimeline);
- connect(timelineWidget, &ReplayTimelineWidget::processNextEvent, this, &ReplayWidget::replayNextEvent);
- connect(timelineWidget, &ReplayTimelineWidget::replayFinished, this, &ReplayWidget::replayFinished);
- connect(timelineWidget, &ReplayTimelineWidget::rewound, this, &ReplayWidget::replayRewind);
+ timelineWidget->setTimeline(replayManager->getReplayTimeline());
+ connect(replayManager, &ReplayManager::timeChanged, timelineWidget, &ReplayTimelineWidget::setCurrentTime);
+ connect(timelineWidget, &ReplayTimelineWidget::timeClicked, replayManager, &ReplayManager::setTime);
// timeline skip shortcuts
aReplaySkipForward = new QAction(timelineWidget);
timelineWidget->addAction(aReplaySkipForward);
connect(aReplaySkipForward, &QAction::triggered, this,
- [this] { timelineWidget->skipByAmount(ReplayTimelineWidget::SMALL_SKIP_MS); });
+ [this] { replayManager->skipByAmount(ReplayManager::SMALL_SKIP_MS); });
aReplaySkipBackward = new QAction(timelineWidget);
timelineWidget->addAction(aReplaySkipBackward);
connect(aReplaySkipBackward, &QAction::triggered, this,
- [this] { timelineWidget->skipByAmount(-ReplayTimelineWidget::SMALL_SKIP_MS); });
+ [this] { replayManager->skipByAmount(-ReplayManager::SMALL_SKIP_MS); });
aReplaySkipForwardBig = new QAction(timelineWidget);
timelineWidget->addAction(aReplaySkipForwardBig);
connect(aReplaySkipForwardBig, &QAction::triggered, this,
- [this] { timelineWidget->skipByAmount(ReplayTimelineWidget::BIG_SKIP_MS); });
+ [this] { replayManager->skipByAmount(ReplayManager::BIG_SKIP_MS); });
aReplaySkipBackwardBig = new QAction(timelineWidget);
timelineWidget->addAction(aReplaySkipBackwardBig);
connect(aReplaySkipBackwardBig, &QAction::triggered, this,
- [this] { timelineWidget->skipByAmount(-ReplayTimelineWidget::BIG_SKIP_MS); });
+ [this] { replayManager->skipByAmount(-ReplayManager::BIG_SKIP_MS); });
// buttons
replayPlayButton = new QToolButton;
@@ -86,6 +66,10 @@ ReplayWidget::ReplayWidget(TabGame *parent, GameReplay *_replay)
settingsWidget->setFixedSize(QSize(32, 32));
connect(settingsWidget, &ReplayQuickSettingsWidget::fastForwardSpeedChanged, this,
[this] { updateTimeScaleFactor(replayFastForwardButton->isChecked()); });
+ connect(settingsWidget, &ReplayQuickSettingsWidget::skipEmptySectionsChanged, replayManager,
+ &ReplayManager::setSkipEmptySections);
+
+ replayManager->setSkipEmptySections(SettingsCache::instance().userInterface().getSkipEmptySections());
// putting everything together
auto replayControlLayout = new QHBoxLayout;
@@ -97,18 +81,11 @@ ReplayWidget::ReplayWidget(TabGame *parent, GameReplay *_replay)
setObjectName("replayControlWidget");
setLayout(replayControlLayout);
- connect(this, &ReplayWidget::requestChatAndPhaseReset, game, &TabGame::resetChatAndPhase);
-
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&ReplayWidget::refreshShortcuts);
refreshShortcuts();
}
-void ReplayWidget::replayNextEvent(EventProcessingOptions options)
-{
- emit eventReplayed(replay->event_list(timelineWidget->getCurrentEvent()), options);
-}
-
void ReplayWidget::replayFinished()
{
replayPlayButton->setChecked(false);
@@ -117,24 +94,16 @@ void ReplayWidget::replayFinished()
void ReplayWidget::replayPlayButtonToggled(bool checked)
{
if (checked) { // start replay
- timelineWidget->startReplay();
+ replayManager->startReplay();
} else { // pause replay
- timelineWidget->stopReplay();
+ replayManager->stopReplay();
}
}
void ReplayWidget::updateTimeScaleFactor(bool isFastForward)
{
- qreal factor = isFastForward ? SettingsCache::instance().interface().getFastForwardSpeed() : 1.0;
- timelineWidget->setTimeScaleFactor(factor);
-}
-
-/**
- * @brief Handles everything that needs to be reset when doing a replay rewind.
- */
-void ReplayWidget::replayRewind()
-{
- emit requestChatAndPhaseReset();
+ qreal factor = isFastForward ? SettingsCache::instance().userInterface().getFastForwardSpeed() : 1.0;
+ replayManager->setTimeScaleFactor(factor);
}
void ReplayWidget::refreshShortcuts()
diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h
index eca356e9e..6d2b7a043 100644
--- a/cockatrice/src/interface/widgets/replay/replay_widget.h
+++ b/cockatrice/src/interface/widgets/replay/replay_widget.h
@@ -14,11 +14,12 @@
#include
#include
+class ReplayManager;
class ReplayQuickSettingsWidget;
class TabGame;
/**
- * @brief The top-level that is put in the replay dock widget.
+ * @brief The top-level widget that is put in the replay dock widget.
* Contains the replay timeline as well as the buttons.
*/
class ReplayWidget : public QWidget
@@ -26,29 +27,28 @@ class ReplayWidget : public QWidget
Q_OBJECT
public:
- ReplayWidget(TabGame *parent, GameReplay *replay);
- TabGame *game;
- GameReplay *replay;
+ /**
+ * @param parent The parent widget
+ * @param replay Cannot be null. Takes ownership of the replay.
+ */
+ ReplayWidget(QWidget *parent, GameReplay *replay);
signals:
- void requestChatAndPhaseReset();
+ void rewound();
void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options);
private:
- // Replay related members
- int currentReplayStep = 0;
- QList replayTimeline;
+ ReplayManager *replayManager;
+
ReplayTimelineWidget *timelineWidget;
QToolButton *replayPlayButton, *replayFastForwardButton;
ReplayQuickSettingsWidget *settingsWidget;
QAction *aReplaySkipForward, *aReplaySkipBackward, *aReplaySkipForwardBig, *aReplaySkipBackwardBig;
private slots:
- void replayNextEvent(EventProcessingOptions options);
void replayFinished();
void replayPlayButtonToggled(bool checked);
void updateTimeScaleFactor(bool checked);
- void replayRewind();
void refreshShortcuts();
};
diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp
index 2415c3bb4..064064380 100644
--- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp
+++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp
@@ -13,6 +13,7 @@
#include
#include
#include
+#include
#include
#include
@@ -62,12 +63,14 @@ void ChatView::adjustColorsToPalette()
serverMessageColor = QColor(0xFF, 0x73, 0x83);
otherUserColor = otherUserColor.lighter(150);
linkColor = QColor(71, 158, 252);
+ unresolvedCardTagColor = QColor(0xFF, 0xA5, 0x00);
} else {
document()->setDefaultStyleSheet(R"(
a { text-decoration: none; color: blue; }
.blue { color: blue }
)");
linkColor = palette().link().color();
+ unresolvedCardTagColor = QColor(0xA0, 0x52, 0x2D);
}
QTimer::singleShot(0, this, &ChatView::refreshBlockColors);
@@ -173,13 +176,22 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold,
void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName)
{
QTextCharFormat oldFormat = cursor.charFormat();
- QTextCharFormat anchorFormat = oldFormat;
- anchorFormat.setForeground(linkColor);
- anchorFormat.setAnchor(true);
- anchorFormat.setAnchorHref("card://" + cardName);
- anchorFormat.setFontItalic(true);
+ QTextCharFormat cardFormat = oldFormat;
+ cardFormat.setFontItalic(true);
- cursor.setCharFormat(anchorFormat);
+ if (!CardDatabaseManager::query()->lookupCardByName(cardName)) {
+ cardFormat.setForeground(unresolvedCardTagColor);
+ cursor.setCharFormat(cardFormat);
+ cursor.insertText(cardName);
+ cursor.setCharFormat(oldFormat);
+ return;
+ }
+
+ cardFormat.setForeground(linkColor);
+ cardFormat.setAnchor(true);
+ cardFormat.setAnchorHref("card://" + cardName);
+
+ cursor.setCharFormat(cardFormat);
cursor.insertText(cardName);
cursor.setCharFormat(oldFormat);
}
diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h
index 506605d24..8d5894613 100644
--- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h
+++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h
@@ -81,6 +81,7 @@ private:
QColor otherUserColor = QColor(0, 65, 255); // dark blue
QColor serverMessageColor = QColor(0x85, 0x15, 0x15);
QColor linkColor;
+ QColor unresolvedCardTagColor;
private slots:
void openLink(const QUrl &link);
diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp
index e9fa3c3cf..6580f0262 100644
--- a/cockatrice/src/interface/widgets/server/game_selector.cpp
+++ b/cockatrice/src/interface/widgets/server/game_selector.cpp
@@ -10,18 +10,23 @@
#include "games_model.h"
#include "user/user_list_manager.h"
+#include
#include
+#include
#include
#include
#include
#include
#include
+#include